Merge branch 'MDL-57633-master' of git://github.com/jleyva/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 21 Feb 2017 22:45:14 +0000 (23:45 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 21 Feb 2017 22:45:14 +0000 (23:45 +0100)
257 files changed:
admin/cli/cfg.php [new file with mode: 0644]
admin/settings/security.php
admin/settings/subsystems.php
admin/tool/behat/cli/run.php
admin/tool/behat/cli/util.php
admin/tool/behat/cli/util_single_run.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/mobile/version.php
admin/tool/usertours/tests/manager_test.php
auth/classes/output/login.php
backup/backup.class.php
backup/controller/restore_controller.class.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_course_task.class.php
backup/moodle2/restore_root_task.class.php
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/moodle2_test.php
backup/util/checks/restore_check.class.php
backup/util/ui/renderer.php
blocks/activity_results/edit_form.php
blocks/login/block_login.php
blocks/rss_client/block_rss_client.php
blocks/rss_client/classes/output/footer.php
blocks/rss_client/lang/en/block_rss_client.php
blocks/rss_client/managefeeds.php
blocks/rss_client/templates/footer.mustache
blocks/site_main_menu/block_site_main_menu.php
blocks/site_main_menu/tests/behat/add_url.feature
blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php [new file with mode: 0644]
blocks/site_main_menu/tests/behat/edit_activities.feature
blocks/social_activities/block_social_activities.php
blocks/social_activities/tests/behat/behat_block_social_activities.php [new file with mode: 0644]
blocks/social_activities/tests/behat/edit_activities.feature
comment/comment.js
comment/lib.php
completion/tests/behat/restrict_activity_by_date.feature
completion/tests/behat/restrict_activity_by_grade.feature
composer.json
composer.lock
course/amd/build/actions.min.js [new file with mode: 0644]
course/amd/src/actions.js [new file with mode: 0644]
course/classes/management_renderer.php
course/externallib.php
course/format/lib.php
course/format/renderer.php
course/format/social/lib.php
course/format/topics/lib.php
course/format/topics/renderer.php
course/format/upgrade.txt
course/format/weeks/lib.php
course/lib.php
course/mod.php
course/modlib.php
course/moodleform_mod.php
course/renderer.php
course/rest.php
course/tests/behat/activities_visibility_icons.feature
course/tests/behat/behat_course.php
course/tests/behat/course_search.feature
course/tests/courselib_test.php
course/tests/externallib_test.php
course/tests/modlib_test.php
course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-debug.js
course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop-min.js
course/yui/build/moodle-course-dragdrop/moodle-course-dragdrop.js
course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-debug.js [deleted file]
course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes-min.js [deleted file]
course/yui/build/moodle-course-toolboxes/moodle-course-toolboxes.js [deleted file]
course/yui/src/dragdrop/js/resource.js
course/yui/src/toolboxes/build.json [deleted file]
course/yui/src/toolboxes/js/resource.js [deleted file]
course/yui/src/toolboxes/js/section.js [deleted file]
course/yui/src/toolboxes/js/shared.js [deleted file]
course/yui/src/toolboxes/js/toolbox.js [deleted file]
course/yui/src/toolboxes/meta/toolboxes.json [deleted file]
enrol/lti/classes/helper.php
enrol/lti/classes/manage_table.php
enrol/lti/index.php
enrol/lti/lang/en/deprecated.txt
enrol/lti/lang/en/enrol_lti.php
enrol/lti/templates/copy_grid.mustache
enrol/lti/tests/helper_test.php
enrol/self/lib.php
filter/mediaplugin/filter.php
grade/report/grader/lib.php
grade/report/grader/tests/behat/switch_views.feature
install/lang/es_mx/error.php
install/lang/fa/error.php
install/lang/lv/admin.php
install/lang/lv/install.php
install/lang/sr_cr/install.php
install/lang/sr_lt/install.php
lang/en/admin.php
lang/en/backup.php
lang/en/deprecated.txt
lang/en/moodle.php
lib/accesslib.php
lib/amd/build/ajax.min.js
lib/amd/build/form-autocomplete.min.js
lib/amd/build/str.min.js
lib/amd/build/templates.min.js
lib/amd/src/ajax.js
lib/amd/src/form-autocomplete.js
lib/amd/src/str.js
lib/amd/src/templates.js
lib/behat/classes/behat_command.php
lib/behat/classes/behat_config_manager.php
lib/behat/classes/behat_config_util.php
lib/behat/classes/util.php
lib/behat/lib.php
lib/classes/event/base.php
lib/classes/event/user_graded.php
lib/classes/external/exporter.php
lib/classes/files/curl_security_helper.php
lib/classes/notification.php
lib/classes/persistent.php
lib/classes/session/manager.php
lib/db/install.xml
lib/db/services.php
lib/db/upgrade.php
lib/db/upgradelib.php
lib/deprecatedlib.php
lib/editor/tinymce/plugins/moodlemedia/preview.php
lib/filestorage/file_storage.php
lib/filestorage/file_system.php [new file with mode: 0644]
lib/filestorage/file_system_filedir.php [new file with mode: 0644]
lib/filestorage/stored_file.php
lib/filestorage/tests/file_storage_test.php
lib/filestorage/tests/file_system_filedir_test.php [new file with mode: 0644]
lib/filestorage/tests/file_system_test.php [new file with mode: 0644]
lib/filestorage/tests/fixtures/test.tgz [new file with mode: 0644]
lib/filestorage/tests/mbz_packer_test.php
lib/form/button.php
lib/form/modvisible.php
lib/grade/grade_category.php
lib/grade/grade_item.php
lib/javascript-static.js
lib/modinfolib.php
lib/moodlelib.php
lib/navigationlib.php
lib/outputrenderers.php
lib/phpunit/classes/util.php
lib/setup.php
lib/setuplib.php
lib/templates/availability_info.mustache [new file with mode: 0644]
lib/templates/login.mustache
lib/testing/generator/module_generator.php
lib/tests/accesslib_test.php
lib/tests/behat/behat_action_menu.php
lib/tests/behat/behat_deprecated.php
lib/tests/exporter_test.php
lib/tests/medialib_test.php
lib/tests/persistent_test.php
lib/tests/upgradelib_test.php
lib/tests/weblib_test.php
lib/upgrade.txt
lib/weblib.php
login/lib.php
login/set_password_form.php
media/classes/manager.php
media/player/videojs/tests/behat/modules.feature [new file with mode: 0644]
media/player/videojs/tests/fixtures/test.mov [new file with mode: 0644]
media/upgrade.txt [new file with mode: 0644]
message/classes/api.php
message/lib.php
message/output/popup/db/install.xml
message/output/popup/db/upgrade.php
message/output/popup/tests/externallib_test.php
message/output/popup/version.php
message/tests/api_test.php
message/tests/externallib_test.php
message/tests/messagelib_test.php
mod/assign/amd/build/grading_navigation.min.js
mod/assign/amd/build/grading_panel.min.js
mod/assign/amd/build/participant_selector.min.js
mod/assign/amd/src/grading_navigation.js
mod/assign/amd/src/grading_panel.js
mod/assign/amd/src/participant_selector.js
mod/assign/classes/output/grading_app.php
mod/assign/externallib.php
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/feedback/editpdf/classes/pdf.php
mod/assign/feedback/editpdf/tests/editpdf_test.php
mod/assign/feedback/file/locallib.php
mod/assign/gradingoptionsform.php
mod/assign/gradingtable.php
mod/assign/lang/en/assign.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/templates/grading_navigation_user_selector.mustache
mod/assign/tests/externallib_test.php
mod/assign/tests/lib_test.php
mod/assign/tests/locallib_test.php
mod/feedback/lib.php
mod/folder/classes/external.php
mod/folder/lib.php
mod/folder/renderer.php
mod/folder/styles.css [new file with mode: 0644]
mod/folder/tests/externallib_test.php
mod/forum/classes/output/big_search_form.php
mod/forum/externallib.php
mod/forum/search.php
mod/forum/templates/big_search_form.mustache
mod/glossary/import.php
mod/label/classes/external.php
mod/label/tests/behat/label_visibility.feature
mod/label/tests/externallib_test.php
mod/lesson/locallib.php
mod/page/classes/external.php
mod/page/tests/externallib_test.php
mod/quiz/tests/behat/editing_section_headings.feature
mod/resource/classes/external.php
mod/resource/locallib.php
mod/resource/tests/externallib_test.php
mod/scorm/datamodels/aicc.js
mod/scorm/datamodels/scorm_12.js
mod/scorm/datamodels/scorm_12.php
mod/scorm/datamodels/scorm_13.js
mod/survey/amd/build/validation.min.js [new file with mode: 0644]
mod/survey/amd/src/validation.js [new file with mode: 0644]
mod/survey/lib.php
mod/survey/survey.js [deleted file]
mod/survey/view.php
mod/url/classes/external.php
mod/url/locallib.php
mod/url/tests/externallib_test.php
mod/workshop/locallib.php
my/lib.php
question/engine/questionattempt.php
question/engine/tests/walkthrough_test.php [new file with mode: 0644]
question/format/blackboard_six/formatbase.php
question/type/gapselect/questionbase.php
question/type/match/question.php
question/type/multichoice/question.php
question/type/questionbase.php
question/type/upgrade.txt
repository/lib.php
rss/file.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/grade.scss
theme/boost/templates/core/availability_info.mustache [new file with mode: 0644]
theme/boost/templates/core/login.mustache
theme/boost/templates/core/tabtree.mustache
theme/boost/templates/mod_assign/grading_navigation_user_selector.mustache
theme/boost/templates/mod_forum/big_search_form.mustache
theme/boost/tests/behat/behat_theme_boost_behat_action_menu.php
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/less/moodle/course.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/core/availability_info.mustache [new file with mode: 0644]
user/tests/externallib_test.php
version.php
webservice/tests/externallib_test.php

diff --git a/admin/cli/cfg.php b/admin/cli/cfg.php
new file mode 100644 (file)
index 0000000..463a0ab
--- /dev/null
@@ -0,0 +1,173 @@
+<?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/>.
+
+/**
+ * CLI script allowing to get and set config values.
+ *
+ * This is technically just a thin wrapper for {@link get_config()} and
+ * {@link set_config()} functions.
+ *
+ * @package     core
+ * @subpackage  cli
+ * @copyright   2017 David Mudr├ík <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', true);
+
+require(__DIR__.'/../../config.php');
+require_once($CFG->libdir.'/clilib.php');
+
+$usage = "Displays the current value of the given site setting. Allows to set it to the given value, too.
+
+Usage:
+    # php cfg.php [--component=<componentname>] [--json] [--shell-arg]
+    # php cfg.php --name=<configname> [--component=<componentname>] [--shell-arg] [--no-eol]
+    # php cfg.php --name=<configname> [--component=<componentname>] --set=<value>
+    # php cfg.php --name=<configname> [--component=<componentname>] --unset
+    # php cfg.php [--help|-h]
+
+Options:
+    -h --help                   Print this help.
+    --component=<frankenstyle>  Name of the component the variable is part of. Defaults to core.
+    --name=<configname>         Name of the configuration variable to get/set. If missing, print all
+                                configuration variables of the given component.
+    --set=<value>               Set the given variable to this value.
+    --unset                     Unset the given variable.
+    --shell-arg                 Escape output values so that they can be directly used as shell script arguments.
+    --json                      Encode output list of values using JSON notation.
+    --no-eol                    Do not include the trailing new line character when printing the value.
+
+The list of all variables of the given component can be printed as
+tab-separated list (default) or JSON object (--json). Particular values are
+printed as raw text values, optionally escaped so that they can be directly
+used as shell script arguments (--shell-arg). Single values are displayed with
+trailing new line by default, unless explicitly disabled (--no-eol).
+
+In the read mode, the script exits with success status 0 if the requested value
+is found. If the requested variable is not set, the script exits with status 3.
+When listing all variables of the component, the exit status is always 0 even
+if no variables for the given component are found. When setting/unsetting a
+value, the exit status is 0. When attempting to set/unset a value that has
+already been hard-set in config.php, the script exits with error status 4. In
+case of unexpected error, the script exits with error status 1.
+
+Examples:
+
+    # php cfg.php
+        Prints tab-separated list of all core configuration variables and their values.
+
+    # php cfg.php --json
+        Prints list of all core configuration variables and their values as a JSON object.
+
+    # php cfg.php --name=release
+        Prints the given configuration variable - e.g. \$CFG->release in this case.
+
+    # php cfg.php --component=tool_recyclebin
+    #   Prints tab-separated list of the plugin's configuration variables.
+
+    # export DATAROOT=\$(php cfg.php --name=dataroot --shell-arg --no-eol)
+        Stores the given configuration variable in the shell variable, escaped
+        so that it can be safely used as a shell argument.
+
+    # php cfg.php --name=theme --set=clean
+        Sets the given configuration variable to the given value.
+
+    # php cfg.php --name=noemailever --unset
+        Unsets the previously configured variable.
+";
+
+list($options, $unrecognised) = cli_get_params([
+    'help' => false,
+    'component' => null,
+    'name' => null,
+    'set' => null,
+    'unset' => false,
+    'shell-arg' => false,
+    'json' => false,
+    'no-eol' => false,
+], [
+    'h' => 'help'
+]);
+
+if ($unrecognised) {
+    $unrecognised = implode(PHP_EOL.'  ', $unrecognised);
+    cli_error(get_string('cliunknowoption', 'core_admin', $unrecognised));
+}
+
+if ($options['help']) {
+    cli_writeln($usage);
+    exit(2);
+}
+
+if ($options['unset'] || $options['set'] !== null) {
+    // Unset the variable or set it to the given value.
+    if (empty($options['name'])) {
+        cli_error('Missing configuration variable name', 2);
+    }
+
+    // Check that the variable is not hard-set in the main config.php already.
+    if (array_key_exists($options['name'], $CFG->config_php_settings)) {
+        cli_error('The configuration variable is hard-set in the config.php, unable to change.', 4);
+    }
+
+    set_config($options['name'], $options['set'], $options['component']);
+    exit(0);
+}
+
+if ($options['name'] === null) {
+    // List all variables provided by the component (defaults to core).
+    $got = get_config($options['component']);
+
+    if ($options['json']) {
+        cli_writeln(json_encode($got));
+
+    } else {
+        foreach ($got as $name => $value) {
+            if ($options['shell-arg']) {
+                $value = escapeshellarg($value);
+            }
+            cli_writeln($name."\t".$value);
+        }
+    }
+
+    exit(0);
+
+} else {
+    // Display the value of a single variable.
+
+    $got = get_config($options['component'], $options['name']);
+
+    if ($got === false) {
+        cli_error('No such configuration variable found.', 3);
+    }
+
+    if ($options['shell-arg']) {
+        $got = escapeshellarg($got);
+    }
+
+    if ($options['json']) {
+        $got = json_encode($got);
+    }
+
+    if ($options['no-eol']) {
+        cli_write($got);
+    } else {
+        cli_writeln($got);
+    }
+
+    exit(0);
+}
index 1d89d42..793205d 100644 (file)
@@ -117,7 +117,6 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configcheckbox('cookiesecure', new lang_string('cookiesecure', 'admin'), new lang_string('configcookiesecure', 'admin'), 1));
     $temp->add(new admin_setting_configcheckbox('cookiehttponly', new lang_string('cookiehttponly', 'admin'), new lang_string('configcookiehttponly', 'admin'), 0));
     $temp->add(new admin_setting_configcheckbox('allowframembedding', new lang_string('allowframembedding', 'admin'), new lang_string('allowframembedding_help', 'admin'), 0));
-    $temp->add(new admin_setting_configcheckbox('loginpasswordautocomplete', new lang_string('loginpasswordautocomplete', 'admin'), new lang_string('loginpasswordautocomplete_help', 'admin'), 0));
 
     // Settings elements used by the \core\files\curl_security_helper class.
     $temp->add(new admin_setting_configmixedhostiplist('curlsecurityblockedhosts',
index cc65569..6f5ded5 100644 (file)
@@ -54,4 +54,10 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
 
     $optionalsubsystems->add(new admin_setting_configcheckbox('enableglobalsearch', new lang_string('enableglobalsearch', 'admin'),
         new lang_string('enableglobalsearch_desc', 'admin'), 0, 1, 0));
+
+    $choices = array();
+    $choices[0] = new lang_string('no');
+    $choices[1] = new lang_string('yes');
+    $optionalsubsystems->add(new admin_setting_configselect('allowstealth', new lang_string('allowstealthmodules'),
+        new lang_string('allowstealthmodules_help'), 0, $choices));
 }
index a228caf..e402efa 100644 (file)
@@ -54,6 +54,8 @@ list($options, $unrecognised) = cli_get_params(
         'fromrun'  => 1,
         'torun'    => 0,
         'single-run' => false,
+        'rerun' => 0,
+        'auto-rerun' => 0,
     ),
     array(
         'h' => 'help',
@@ -77,6 +79,8 @@ Options:
 --replace          Replace args string with run process number, useful for output.
 --fromrun          Execute run starting from (Used for parallel runs on different vms)
 --torun            Execute run till (Used for parallel runs on different vms)
+--rerun            Re-run scenarios that failed during last execution.
+--auto-rerun       Automatically re-run scenarios that failed during last execution.
 
 -h, --help         Print out this help
 
@@ -91,11 +95,31 @@ if (!empty($options['help'])) {
     exit(0);
 }
 
-$parallelrun = behat_config_manager::get_parallel_test_runs($options['fromrun']);
+$parallelrun = behat_config_manager::get_behat_run_config_value('parallel');
 
-// Default torun is maximum parallel runs.
-if (empty($options['torun'])) {
-    $options['torun'] = $parallelrun;
+// Check if the options provided are valid to run behat.
+if ($parallelrun === false) {
+    // Parallel run should not have fromrun or torun options greater than 1.
+    if (($options['fromrun'] > 1) || ($options['torun'] > 1)) {
+        echo "Test site is not initialized  for parallel run." . PHP_EOL;
+        exit(1);
+    }
+} else {
+    // Ensure fromrun is within limits of initialized test site.
+    if (!empty($options['fromrun']) && ($options['fromrun'] > $parallelrun)) {
+        echo "From run (" . $options['fromrun'] . ") is more than site with parallel runs (" . $parallelrun . ")" . PHP_EOL;
+        exit(1);
+    }
+
+    // Default torun is maximum parallel runs and should be less than equal to parallelruns.
+    if (empty($options['torun'])) {
+        $options['torun'] = $parallelrun;
+    } else {
+        if ($options['torun'] > $parallelrun) {
+            echo "To run (" . $options['torun'] . ") is more than site with parallel runs (" . $parallelrun . ")" . PHP_EOL;
+            exit(1);
+        }
+    }
 }
 
 // Capture signals and ensure we clean symlinks.
@@ -127,7 +151,7 @@ if ($options['profile']) {
         exit(1);
     }
 
-    $extraopts[] = '--profile="' . $profile . '"';
+    $extraopts['profile'] = '--profile="' . $profile . '"';
     // By default, profile tags will be used.
     if (!empty($CFG->behat_config[$profile]['filters']['tags'])) {
         $tags = $CFG->behat_config[$profile]['filters']['tags'];
@@ -137,117 +161,200 @@ if ($options['profile']) {
 // Command line tags have precedence (std behat behavior).
 if ($options['tags']) {
     $tags = $options['tags'];
-    $extraopts[] = '--tags="' . $tags . '"';
+    $extraopts['tags'] = '--tags="' . $tags . '"';
 }
 
 // Add suite option if specified.
 if ($options['suite']) {
-    $extraopts[] = '--suite="' . $options['suite'] . '"';
+    $extraopts['suite'] = '--suite="' . $options['suite'] . '"';
 }
 
 // Feature should be added to last, for behat command.
 if ($options['feature']) {
-    $extraopts[] = $options['feature'];
+    $extraopts['feature'] = $options['feature'];
     // Only run 1 process as process.
     // Feature file is picked from absolute path provided, so no need to check for behat.yml.
     $options['torun'] = $options['fromrun'];
 }
 
 // Set of options to pass to behat.
-$extraopts = implode(' ', $extraopts);
+$extraoptstr = implode(' ', $extraopts);
+
+// If rerun is passed then ensure we just run the failed processes.
+$lastfailedstatus = 0;
+$lasttorun = $options['torun'];
+$lastfromrun = $options['fromrun'];
+if ($options['rerun']) {
+    // Get last combined failed status.
+    $lastfailedstatus = behat_config_manager::get_behat_run_config_value('lastcombinedfailedstatus');
+    $lasttorun = behat_config_manager::get_behat_run_config_value('lasttorun');
+    $lastfromrun = behat_config_manager::get_behat_run_config_value('lastfromrun');
+
+    if ($lastfailedstatus !== false) {
+        $extraoptstr .= ' --rerun';
+    }
+
+    // If torun is less than last torun, then just set this to min last to run and similar for fromrun.
+    if ($options['torun'] < $lasttorun) {
+        $options['torun'];
+    }
+    if ($options['fromrun'] > $lastfromrun) {
+        $options['fromrun'];
+    }
+    unset($options['rerun']);
+}
+
+$cmds = array();
+$exitcodes = array();
+$status = 0;
+$verbose = empty($options['verbose']) ? false : true;
 
-// If empty parallelrun then just check with user if it's a run single behat test.
+// Execute behat run commands.
 if (empty($parallelrun)) {
     $cwd = getcwd();
     chdir(__DIR__);
     $runtestscommand = behat_command::get_behat_command(false, false, true);
     $runtestscommand .= ' --config ' . behat_config_manager::get_behat_cli_config_filepath();
-    $runtestscommand .= ' ' . $extraopts;
+    $runtestscommand .= ' ' . $extraoptstr;
+    $cmds['singlerun'] = $runtestscommand;
+
     echo "Running single behat site:" . PHP_EOL;
-    passthru("php $runtestscommand", $code);
+    passthru("php $runtestscommand", $status);
+    $exitcodes['singlerun'] = $status;
     chdir($cwd);
-    exit($code);
-}
-
-$cmds = array();
-echo "Running " . ($options['torun'] - $options['fromrun'] + 1) . " parallel behat sites:" . PHP_EOL;
-
-for ($i = $options['fromrun']; $i <= $options['torun']; $i++) {
-    $CFG->behatrunprocess = $i;
+} else {
 
-    // Options parameters to be added to each run.
-    $myopts = !empty($options['replace']) ? str_replace($options['replace'], $i, $extraopts) : $extraopts;
+    echo "Running " . ($options['torun'] - $options['fromrun'] + 1) . " parallel behat sites:" . PHP_EOL;
 
-    $behatcommand = behat_command::get_behat_command(false, false, true);
-    $behatconfigpath = behat_config_manager::get_behat_cli_config_filepath($i);
+    for ($i = $options['fromrun']; $i <= $options['torun']; $i++) {
+        $lastfailed = 1 & $lastfailedstatus >> ($i - 1);
 
-    // Command to execute behat run.
-    $cmds[BEHAT_PARALLEL_SITE_NAME . $i] = $behatcommand . ' --config ' . $behatconfigpath . " " . $myopts;
-    echo "[" . BEHAT_PARALLEL_SITE_NAME . $i . "] " . $cmds[BEHAT_PARALLEL_SITE_NAME . $i] . PHP_EOL;
-}
+        // Bypass if not failed in last run.
+        if ($lastfailedstatus && !$lastfailed && ($i <= $lasttorun) && ($i >= $lastfromrun)) {
+            continue;
+        }
 
-if (empty($cmds)) {
-    echo "No commands to execute " . PHP_EOL;
-    exit(1);
-}
+        $CFG->behatrunprocess = $i;
 
-// Create site symlink if necessary.
-if (!behat_config_manager::create_parallel_site_links($options['fromrun'], $options['torun'])) {
-    echo "Check permissions. If on windows, make sure you are running this command as admin" . PHP_EOL;
-    exit(1);
-}
+        // Options parameters to be added to each run.
+        $myopts = !empty($options['replace']) ? str_replace($options['replace'], $i, $extraoptstr) : $extraoptstr;
 
-// Execute all commands, relative to moodle root directory.
-$processes = cli_execute_parallel($cmds, __DIR__ . "/../../../../");
-$stoponfail = empty($options['stop-on-failure']) ? false : true;
+        $behatcommand = behat_command::get_behat_command(false, false, true);
+        $behatconfigpath = behat_config_manager::get_behat_cli_config_filepath($i);
 
-// Print header.
-print_process_start_info($processes);
+        // Command to execute behat run.
+        $cmds[BEHAT_PARALLEL_SITE_NAME . $i] = $behatcommand . ' --config ' . $behatconfigpath . " " . $myopts;
+        echo "[" . BEHAT_PARALLEL_SITE_NAME . $i . "] " . $cmds[BEHAT_PARALLEL_SITE_NAME . $i] . PHP_EOL;
+    }
 
-// Print combined run o/p from processes.
-$exitcodes = print_combined_run_output($processes, $stoponfail);
-$time = round(microtime(true) - $time, 1);
-echo "Finished in " . gmdate("G\h i\m s\s", $time) . PHP_EOL . PHP_EOL;
+    if (empty($cmds)) {
+        echo "No commands to execute " . PHP_EOL;
+        exit(1);
+    }
 
-ksort($exitcodes);
+    // Create site symlink if necessary.
+    if (!behat_config_manager::create_parallel_site_links($options['fromrun'], $options['torun'])) {
+        echo "Check permissions. If on windows, make sure you are running this command as admin" . PHP_EOL;
+        exit(1);
+    }
 
-// Print exit info from each run.
-// Status bits contains pass/fail status of parallel runs.
-$status = 0;
-$processcounter = 0;
-foreach ($exitcodes as $exitcode) {
-    if ($exitcode) {
-        $status |= (1 << $processcounter);
+    // Save torun and from run, so it can be used to detect if it was executed in last run.
+    behat_config_manager::set_behat_run_config_value('lasttorun', $options['torun']);
+    behat_config_manager::set_behat_run_config_value('lastfromrun', $options['fromrun']);
+
+    // Execute all commands, relative to moodle root directory.
+    $processes = cli_execute_parallel($cmds, __DIR__ . "/../../../../");
+    $stoponfail = empty($options['stop-on-failure']) ? false : true;
+
+    // Print header.
+    print_process_start_info($processes);
+
+    // Print combined run o/p from processes.
+    $exitcodes = print_combined_run_output($processes, $stoponfail);
+    // Time to finish run.
+    $time = round(microtime(true) - $time, 1);
+    echo "Finished in " . gmdate("G\h i\m s\s", $time) . PHP_EOL . PHP_EOL;
+    ksort($exitcodes);
+
+    // Print exit info from each run.
+    // Status bits contains pass/fail status of parallel runs.
+    foreach ($exitcodes as $name => $exitcode) {
+        if ($exitcode) {
+            $runno = str_replace(BEHAT_PARALLEL_SITE_NAME, '', $name);
+            $status |= (1 << ($runno - 1));
+        }
     }
-    $processcounter++;
+
+    // Print each process information.
+    print_each_process_info($processes, $verbose, $status);
 }
 
-// Run finished. Show exit code and output from individual process.
-$verbose = empty($options['verbose']) ? false : true;
-$verbose = $verbose || !empty($status);
-
-// Show exit code from each process, if any process failed.
-if ($verbose) {
-    // Echo exit codes.
-    echo "Exit codes for each behat run: " . PHP_EOL;
-    foreach ($exitcodes as $run => $exitcode) {
-        echo $run . ": " . $exitcode . PHP_EOL;
+// Save final exit code containing which run failed.
+behat_config_manager::set_behat_run_config_value('lastcombinedfailedstatus', $status);
+
+// Show exit code from each process, if any process failed and how to rerun failed process.
+if ($verbose || $status) {
+    // Check if status of last run is failure and rerun is suggested.
+    if (!empty($options['auto-rerun']) && $status) {
+        // Rerun for the number of tries passed.
+        for ($i = 0; $i < $options['auto-rerun']; $i++) {
+
+            // Run individual commands, to avoid parallel failures.
+            foreach ($exitcodes as $behatrunname => $exitcode) {
+                // If not failed in last run, then skip.
+                if ($exitcode == 0) {
+                    continue;
+                }
+
+                // This was a failure.
+                echo "*** Re-running behat run: $behatrunname ***" . PHP_EOL;
+                if ($verbose) {
+                    echo "Executing: " . $cmds[$behatrunname] . " --rerun" . PHP_EOL;
+                }
+
+                passthru("php $cmds[$behatrunname] --rerun", $rerunstatus);
+
+                // Update exit code.
+                $exitcodes[$behatrunname] = $rerunstatus;
+            }
+        }
+
+        // Update status after auto-rerun finished.
+        foreach ($exitcodes as $name => $exitcode) {
+            if ($exitcode) {
+                if (!empty($parallelrun)) {
+                    $runno = str_replace(BEHAT_PARALLEL_SITE_NAME, '', $name);
+                } else {
+                    $runno = 1;
+                }
+                $status |= (1 << ($runno - 1));
+            }
+        }
     }
 
-    // Show failed re-run commands.
+    // Show final o/p with re-run commands.
     if ($status) {
-        echo "To re-run failed processes, you can use following commands:" . PHP_EOL;
-        foreach ($cmds as $name => $cmd) {
-            if (!empty($exitcodes[$name])) {
-                echo "[" . $name . "] " . $cmd . PHP_EOL;
+        if (!empty($parallelrun)) {
+            // Echo exit codes.
+            echo "Exit codes for each behat run: " . PHP_EOL;
+            foreach ($exitcodes as $run => $exitcode) {
+                echo $run . ": " . $exitcode . PHP_EOL;
+            }
+            unset($extraopts['fromrun']);
+            unset($extraopts['torun']);
+            if (!empty($options['replace'])) {
+                $extraopts['replace'] = '--replace="' . $options['replace'] . '"';
             }
         }
+
+        echo "To re-run failed processes, you can use following command:" . PHP_EOL;
+        $extraopts['rerun'] = '--rerun';
+        $extraoptstr = implode(' ', $extraopts);
+        echo behat_command::get_behat_command(true, true, true) . " " . $extraoptstr . PHP_EOL;
     }
     echo PHP_EOL;
 }
 
-print_each_process_info($processes, $verbose);
-
 // Remove site symlink if necessary.
 behat_config_manager::drop_parallel_site_links();
 
@@ -364,23 +471,47 @@ function print_combined_run_output($processes, $stoponfail = false) {
  * @param array $processes list of processes to loop though.
  * @param bool $verbose Show verbose output for each process.
  */
-function print_each_process_info($processes, $verbose = false) {
+function print_each_process_info($processes, $verbose = false, $status = 0) {
     foreach ($processes as $name => $process) {
         echo "**************** [" . $name . "] ****************" . PHP_EOL;
         if ($verbose) {
             echo $process->getOutput();
             echo $process->getErrorOutput();
-        } else {
-            $op = explode(PHP_EOL, $process->getOutput());
-            foreach ($op as $line) {
-                // Don't print progress .
-                if (trim($line) && (strpos($line, '.') !== 0) && (strpos($line, 'Moodle ') !== 0) &&
-                    (strpos($line, 'Server OS ') !== 0) && (strpos($line, 'Started at ') !== 0) &&
-                    (strpos($line, 'Browser specific fixes ') !== 0)) {
-                    echo $line . PHP_EOL;
-                }
+
+        } else if ($status) {
+            // Only show failed o/p.
+            $runno = str_replace(BEHAT_PARALLEL_SITE_NAME, '', $name);
+            if ((1 << ($runno - 1)) & $status) {
+                echo $process->getOutput();
+                echo $process->getErrorOutput();
+            } else {
+                echo get_status_lines_from_run_op($process);
             }
+
+        } else {
+            echo get_status_lines_from_run_op($process);
         }
         echo PHP_EOL;
     }
 }
+
+/**
+ * Extract status information from behat o/p and return.
+ * @param Symfony\Component\Process\Process $process
+ * @return string
+ */
+function get_status_lines_from_run_op(Symfony\Component\Process\Process $process) {
+    $statusstr = '';
+    $op = explode(PHP_EOL, $process->getOutput());
+    foreach ($op as $line) {
+        // Don't print progress .
+        if (trim($line) && (strpos($line, '.') !== 0) && (strpos($line, 'Moodle ') !== 0) &&
+            (strpos($line, 'Server OS ') !== 0) && (strpos($line, 'Started at ') !== 0) &&
+            (strpos($line, 'Browser specific fixes ') !== 0)) {
+            $statusstr .= $line . PHP_EOL;
+        }
+    }
+
+    return $statusstr;
+}
+
index b157ca7..8efa4ed 100644 (file)
@@ -36,11 +36,7 @@ define('NO_OUTPUT_BUFFERING', true);
 define('IGNORE_COMPONENT_CACHE', true);
 define('ABORT_AFTER_CONFIG', true);
 
-require_once(__DIR__ . '/../../../../config.php');
 require_once(__DIR__ . '/../../../../lib/clilib.php');
-require_once(__DIR__ . '/../../../../lib/behat/lib.php');
-require_once(__DIR__ . '/../../../../lib/behat/classes/behat_command.php');
-require_once(__DIR__ . '/../../../../lib/behat/classes/behat_config_manager.php');
 
 // CLI options.
 list($options, $unrecognized) = cli_get_params(
@@ -103,14 +99,26 @@ if (!empty($options['help'])) {
 
 $cwd = getcwd();
 
+// If Behat parallel site is being initiliased, then define a param to be used to ignore single run install.
+if (!empty($options['parallel'])) {
+    define('BEHAT_PARALLEL_UTIL', true);
+}
+
+require_once(__DIR__ . '/../../../../config.php');
+require_once(__DIR__ . '/../../../../lib/behat/lib.php');
+require_once(__DIR__ . '/../../../../lib/behat/classes/behat_command.php');
+require_once(__DIR__ . '/../../../../lib/behat/classes/behat_config_manager.php');
+
 // For drop option check if parallel site.
 if ((empty($options['parallel'])) && ($options['drop']) || $options['updatesteps']) {
-    // Get parallel run info from first run.
-    $options['parallel'] = behat_config_manager::get_parallel_test_runs($options['fromrun']);
+    $options['parallel'] = behat_config_manager::get_behat_run_config_value('parallel');
 }
 
 // If not a parallel site then open single run.
 if (empty($options['parallel'])) {
+    // Set run config value for single run.
+    behat_config_manager::set_behat_run_config_value('singlerun', 1);
+
     chdir(__DIR__);
     // Check if behat is initialised, if not exit.
     passthru("php util_single_run.php --diag", $status);
@@ -147,6 +155,21 @@ if ($options['diag'] || $options['enable'] || $options['disable']) {
         $status = (bool)$status || (bool)$exitcode;
     }
 
+    // Remove run config file.
+    $behatrunconfigfile = behat_config_manager::get_behat_run_config_file_path();
+    if (file_exists($behatrunconfigfile)) {
+        if (!unlink($behatrunconfigfile)) {
+            behat_error(BEHAT_EXITCODE_PERMISSIONS, 'Can not delete behat run config file');
+        }
+    }
+
+    // Remove test file path.
+    if (file_exists(behat_util::get_test_file_path())) {
+        if (!unlink(behat_util::get_test_file_path())) {
+            behat_error(BEHAT_EXITCODE_PERMISSIONS, 'Can not delete test file enable info');
+        }
+    }
+
 } else if ($options['install']) {
     // This is intensive compared to behat itself so run them in chunk if option maxruns not set.
     if ($options['maxruns']) {
@@ -233,6 +256,19 @@ if ($options['install']) {
 } else if ($options['enable']) {
     echo "Acceptance tests environment enabled on $CFG->behat_wwwroot, to run the tests use:" . PHP_EOL;
     echo behat_command::get_behat_command(true, true);
+
+    // Save fromrun and to run information.
+    if (isset($options['fromrun'])) {
+        behat_config_manager::set_behat_run_config_value('fromrun', $options['fromrun']);
+    }
+
+    if (isset($options['torun'])) {
+        behat_config_manager::set_behat_run_config_value('torun', $options['torun']);
+    }
+    if (isset($options['parallel'])) {
+        behat_config_manager::set_behat_run_config_value('parallel', $options['parallel']);
+    }
+
     echo PHP_EOL;
 
 } else if ($options['disable']) {
index 3d0e2db..2e98656 100644 (file)
@@ -143,7 +143,7 @@ if ($options['run']) {
     $run = $options['run'];
     // If parallel option is not passed, then try get it form config.
     if (!$options['parallel']) {
-        $parallel = behat_config_manager::get_parallel_test_runs();
+        $parallel = behat_config_manager::get_behat_run_config_value('parallel');
     } else {
         $parallel = $options['parallel'];
     }
@@ -176,10 +176,7 @@ if ($options['install']) {
 } else if ($options['enable']) {
     if (!empty($parallel)) {
         // Save parallel site info for enable and install options.
-        $filepath = behat_config_manager::get_parallel_test_file_path();
-        if (!file_put_contents($filepath, $parallel)) {
-            behat_error(BEHAT_EXITCODE_PERMISSIONS, 'File ' . $filepath . ' can not be created');
-        }
+        behat_config_manager::set_behat_run_config_value('behatsiteenabled', 1);
     }
 
     // Enable test mode.
@@ -200,7 +197,7 @@ if ($options['install']) {
     }
 
 } else if ($options['disable']) {
-    behat_util::stop_test_mode();
+    behat_util::stop_test_mode($run);
     // This is only displayed once for parallel install.
     if (empty($run)) {
         mtrace("Acceptance tests environment disabled");
index dee797a..5e5836b 100644 (file)
@@ -210,6 +210,7 @@ class api {
             $settings->tool_mobile_forcelogout = get_config('tool_mobile', 'forcelogout');
             $settings->tool_mobile_customlangstrings = get_config('tool_mobile', 'customlangstrings');
             $settings->tool_mobile_disabledfeatures = get_config('tool_mobile', 'disabledfeatures');
+            $settings->tool_mobile_custommenuitems = get_config('tool_mobile', 'custommenuitems');
         }
 
         return $settings;
index fca608f..40b1985 100644 (file)
@@ -33,6 +33,15 @@ mm.user.student|Learner|en
 mm.user.student|Aprendiz|es
 </pre>
 For a complete list of string identifiers and more information, see the <a href="{$a}">documentation page</a>.';
+$string['custommenuitems'] = 'Custom menu items';
+$string['custommenuitems_desc'] = 'Additional items can be added to the app\'s main menu by specifying them here. Enter each custom menu item on a new line with format: item text, link URL, link-opening method (inappbrowser, browser or embedded) and language code (optional, for displaying the item to users of the specified language only), separated by pipe characters. For example:
+<pre>
+App\'s help | https://someurl.xyz/help | inappbrowser | en
+Visit our SIS | https://someurl.xyz | browser | en
+My grades | https://someurl.xyz/local/mygrades/index.php | embedded | en
+Mis calificaciones | https://someurl.xyz/local/mygrades/index.php | embedded | es
+</pre>
+Use inappbrowser when you want to open the link in a browser without leaving the app, use browser for opening the link in the device default browser and embedded if you want to display the link embedded in a new page in the app.';
 $string['disabledfeatures'] = 'Disabled features';
 $string['disabledfeatures_desc'] = 'Select here the features you want to disable in the Mobile app for your site. Please note that some features listed here could be already disabled via other site settings. You will have to log out and log in again in the app to see the changes.';
 $string['enablesmartappbanners'] = 'Enable Smart App Banners';
index c55e368..11e3c98 100644 (file)
@@ -100,6 +100,10 @@ if ($hassiteconfig) {
                     new lang_string('disabledfeatures', 'tool_mobile'),
                     new lang_string('disabledfeatures_desc', 'tool_mobile'), array(), $options));
 
+        $temp->add(new admin_setting_configtextarea('tool_mobile/custommenuitems',
+                    new lang_string('custommenuitems', 'tool_mobile'),
+                    new lang_string('custommenuitems_desc', 'tool_mobile'), '', PARAM_RAW, '50', '10'));
+
         $temp->add(new admin_setting_heading('tool_mobile/language',
                     new lang_string('language'), ''));
 
index d8e6aa4..6cf0f49 100644 (file)
@@ -148,6 +148,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
             array('name' => 'tool_mobile_forcelogout', 'value' => 0),
             array('name' => 'tool_mobile_customlangstrings', 'value' => ''),
             array('name' => 'tool_mobile_disabledfeatures', 'value' => ''),
+            array('name' => 'tool_mobile_custommenuitems', 'value' => ''),
         );
         $this->assertCount(0, $result['warnings']);
         $this->assertEquals($expected, $result['settings']);
index 5fdb190..c83e26a 100644 (file)
@@ -23,7 +23,7 @@
  */
 
 defined('MOODLE_INTERNAL') || die();
-$plugin->version   = 2016120503; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2016120504; // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2016112900; // Requires this Moodle version.
 $plugin->component = 'tool_mobile'; // Full name of the plugin (used for diagnostics).
 $plugin->dependencies = array(
index 321ff06..bffecb3 100644 (file)
@@ -118,7 +118,7 @@ class tool_usertours_manager_testcase extends advanced_testcase {
         $rcm = $rc->getMethod($function);
         $rcm->setAccessible(true);
 
-        $this->setExpectedException('moodle_exception', 'A required parameter (sesskey) was missing');
+        $this->expectException('moodle_exception');
         $rcm->invokeArgs($manager, $arguments);
     }
 
index 5b924bb..a58dfe6 100644 (file)
@@ -64,8 +64,6 @@ class login implements renderable, templatable {
     public $instructions;
     /** @var moodle_url The form action login URL. */
     public $loginurl;
-    /** @var bool Whether the password can be auto completed. */
-    public $passwordautocomplete;
     /** @var bool Whether the username should be remembered. */
     public $rememberusername;
     /** @var moodle_url The sign-up URL. */
@@ -90,7 +88,6 @@ class login implements renderable, templatable {
         $this->cookieshelpicon = new help_icon('cookiesenabled', 'core');
 
         $this->autofocusform = !empty($CFG->loginpageautofocus);
-        $this->passwordautocomplete = !empty($CFG->loginpasswordautocomplete);
         $this->rememberusername = isset($CFG->rememberusername) and $CFG->rememberusername == 2;
 
         $this->forgotpasswordurl = new moodle_url($CFG->httpswwwroot . '/login/forgot_password.php');
@@ -149,7 +146,6 @@ class login implements renderable, templatable {
             context_system::instance()->id);
         $data->loginurl = $this->loginurl->out(false);
         $data->rememberusername = $this->rememberusername;
-        $data->passwordautocomplete = $this->passwordautocomplete;
         $data->signupurl = $this->signupurl->out(false);
         $data->username = $this->username;
 
index 3f36f96..0361f10 100644 (file)
@@ -125,6 +125,11 @@ abstract class backup implements checksumable {
     const OPERATION_BACKUP  ='backup'; // We are performing one backup
     const OPERATION_RESTORE ='restore';// We are performing one restore
 
+    // Options for "Include enrolment methods" restore setting.
+    const ENROL_NEVER     = 0;
+    const ENROL_WITHUSERS = 1;
+    const ENROL_ALWAYS    = 2;
+
     // Version and release (to keep CFG->backup_version (and release) updated automatically).
     /**
      * Usually same than major release version, this is used to mark important
index 2829b99..800a2df 100644 (file)
@@ -50,6 +50,7 @@ class restore_controller extends base_controller {
     protected $precheck;    // Results of the execution of restore prechecks
 
     protected $info;   // Information retrieved from backup contents
+    /** @var restore_plan */
     protected $plan;   // Restore execution plan
 
     protected $execution;     // inmediate/delayed
index a51d34d..f81e3be 100644 (file)
@@ -264,7 +264,7 @@ class backup_module_structure_step extends backup_structure_step {
 
         $module = new backup_nested_element('module', array('id', 'version'), array(
             'modulename', 'sectionid', 'sectionnumber', 'idnumber',
-            'added', 'score', 'indent', 'visible',
+            'added', 'score', 'indent', 'visible', 'visibleoncoursepage',
             'visibleold', 'groupmode', 'groupingid',
             'completion', 'completiongradeitemnumber', 'completionview', 'completionexpected',
             'availability', 'showdescription'));
index 733ab1f..49587c8 100644 (file)
@@ -78,7 +78,10 @@ class restore_course_task extends restore_task {
             // No need to do anything with enrolments.
 
         } else if (!$this->get_setting_value('users') or $this->plan->get_mode() == backup::MODE_HUB) {
-            if ($this->get_target() == backup::TARGET_CURRENT_ADDING or $this->get_target() == backup::TARGET_EXISTING_ADDING) {
+            if ($this->get_setting_value('enrolments') == backup::ENROL_ALWAYS && $this->plan->get_mode() != backup::MODE_HUB) {
+                // Restore enrolment methods.
+                $this->add_step(new restore_enrolments_structure_step('course_enrolments', 'enrolments.xml'));
+            } else if ($this->get_target() == backup::TARGET_CURRENT_ADDING or $this->get_target() == backup::TARGET_EXISTING_ADDING) {
                 // Keep current enrolments unchanged.
             } else {
                 // If no instances yet add default enrol methods the same way as when creating new course in UI.
index 42b7c80..eb03318 100644 (file)
@@ -112,12 +112,26 @@ class restore_root_task extends restore_task {
         $users->get_ui()->set_changeable($changeable);
         $this->add_setting($users);
 
-        $rootenrolmanual = new restore_users_setting('enrol_migratetomanual', base_setting::IS_BOOLEAN, false);
-        $rootenrolmanual->set_ui(new backup_setting_ui_checkbox($rootenrolmanual, get_string('rootenrolmanual', 'backup')));
-        $rootenrolmanual->get_ui()->set_changeable(enrol_is_enabled('manual'));
-        $rootenrolmanual->get_ui()->set_changeable($changeable);
-        $this->add_setting($rootenrolmanual);
-        $users->add_dependency($rootenrolmanual);
+        // Restore enrolment methods.
+        if ($changeable) {
+            $options = [
+                backup::ENROL_NEVER     => get_string('rootsettingenrolments_never', 'backup'),
+                backup::ENROL_WITHUSERS => get_string('rootsettingenrolments_withusers', 'backup'),
+                backup::ENROL_ALWAYS    => get_string('rootsettingenrolments_always', 'backup'),
+            ];
+            $enroldefault = backup::ENROL_WITHUSERS;
+        } else {
+            // Users can not be restored, simplify the dropdown.
+            $options = [
+                backup::ENROL_NEVER     => get_string('no'),
+                backup::ENROL_ALWAYS    => get_string('yes')
+            ];
+            $enroldefault = backup::ENROL_NEVER;
+        }
+        $enrolments = new restore_users_setting('enrolments', base_setting::IS_INTEGER, $enroldefault);
+        $enrolments->set_ui(new backup_setting_ui_select($enrolments, get_string('rootsettingenrolments', 'backup'),
+            $options));
+        $this->add_setting($enrolments);
 
         // Define role_assignments (dependent of users)
         $defaultvalue = false;                      // Safer default
index e07b50f..5bf7bdf 100644 (file)
@@ -2154,12 +2154,17 @@ class restore_enrolments_structure_step extends restore_structure_step {
 
     protected function define_structure() {
 
-        $enrol = new restore_path_element('enrol', '/enrolments/enrols/enrol');
-        $enrolment = new restore_path_element('enrolment', '/enrolments/enrols/enrol/user_enrolments/enrolment');
+        $userinfo = $this->get_setting_value('users');
+
+        $paths = [];
+        $paths[] = $enrol = new restore_path_element('enrol', '/enrolments/enrols/enrol');
+        if ($userinfo) {
+            $paths[] = new restore_path_element('enrolment', '/enrolments/enrols/enrol/user_enrolments/enrolment');
+        }
         // Attach local plugin stucture to enrol element.
         $this->add_plugin_structure('enrol', $enrol);
 
-        return array($enrol, $enrolment);
+        return $paths;
     }
 
     /**
@@ -2203,7 +2208,14 @@ class restore_enrolments_structure_step extends restore_structure_step {
         $data->roleid   = $this->get_mappingid('role', $data->roleid);
         $data->courseid = $courserec->id;
 
-        if ($this->get_setting_value('enrol_migratetomanual')) {
+        if (!$this->get_setting_value('users') && $this->get_setting_value('enrolments') == backup::ENROL_WITHUSERS) {
+            $converttomanual = true;
+        } else {
+            $converttomanual = ($this->get_setting_value('enrolments') == backup::ENROL_NEVER);
+        }
+
+        if ($converttomanual) {
+            // Restore enrolments as manual enrolments.
             unset($data->sortorder); // Remove useless sortorder from <2.4 backups.
             if (!enrol_is_enabled('manual')) {
                 $this->set_mapping('enrol', $oldid, 0);
@@ -2224,7 +2236,7 @@ class restore_enrolments_structure_step extends restore_structure_step {
         } else {
             if (!enrol_is_enabled($data->enrol) or !isset($this->plugins[$data->enrol])) {
                 $this->set_mapping('enrol', $oldid, 0);
-                $message = "Enrol plugin '$data->enrol' data can not be restored because it is not enabled, use migration to manual enrolments";
+                $message = "Enrol plugin '$data->enrol' data can not be restored because it is not enabled, consider restoring without enrolment methods";
                 $this->log($message, backup::LOG_WARNING);
                 return;
             }
@@ -2670,7 +2682,7 @@ class restore_calendarevents_structure_step extends restore_structure_step {
                 'courseid'       => $this->get_courseid(),
                 'groupid'        => $data->groupid,
                 'userid'         => $data->userid,
-                'repeatid'       => $data->repeatid,
+                'repeatid'       => $this->get_mappingid('event', $data->repeatid),
                 'modulename'     => $data->modulename,
                 'eventtype'      => $data->eventtype,
                 'timestart'      => $this->apply_date_offset($data->timestart),
@@ -2688,18 +2700,27 @@ class restore_calendarevents_structure_step extends restore_structure_step {
                   FROM {event}
                  WHERE " . $DB->sql_compare_text('name', 255) . " = " . $DB->sql_compare_text('?', 255) . "
                    AND courseid = ?
-                   AND repeatid = ?
                    AND modulename = ?
                    AND timestart = ?
                    AND timeduration = ?
                    AND " . $DB->sql_compare_text('description', 255) . " = " . $DB->sql_compare_text('?', 255);
-        $arg = array ($params['name'], $params['courseid'], $params['repeatid'], $params['modulename'], $params['timestart'], $params['timeduration'], $params['description']);
+        $arg = array ($params['name'], $params['courseid'], $params['modulename'], $params['timestart'], $params['timeduration'], $params['description']);
         $result = $DB->record_exists_sql($sql, $arg);
         if (empty($result)) {
             $newitemid = $DB->insert_record('event', $params);
             $this->set_mapping('event', $oldid, $newitemid);
             $this->set_mapping('event_description', $oldid, $newitemid, $restorefiles);
         }
+        // With repeating events, each event has the repeatid pointed at the first occurrence.
+        // Since the repeatid will be empty when the first occurrence is restored,
+        // Get the repeatid from the second occurrence of the repeating event and use that to update the first occurrence.
+        // Then keep a list of repeatids so we only perform this update once.
+        static $repeatids = array();
+        if (!empty($params['repeatid']) && !in_array($params['repeatid'], $repeatids)) {
+            // This entry is repeated so the repeatid field must be set.
+            $DB->set_field('event', 'repeatid', $params['repeatid'], array('id' => $params['repeatid']));
+            $repeatids[] = $params['repeatid'];
+        }
 
     }
     protected function after_execute() {
index a3b055a..a5f531c 100644 (file)
@@ -541,4 +541,265 @@ class core_backup_moodle2_testcase extends advanced_testcase {
         }
         return $newcmid;
     }
+
+    /**
+     * Help function for enrolment methods backup/restore tests:
+     *
+     * - Creates a course ($course), adds self-enrolment method and a user
+     * - Makes a backup
+     * - Creates a target course (if requested) ($newcourseid)
+     * - Initialises restore controller for this backup file ($rc)
+     *
+     * @param int $target target for restoring: backup::TARGET_NEW_COURSE etc.
+     * @param array $additionalcaps - additional capabilities to give to user
+     * @return array array of original course, new course id, restore controller: [$course, $newcourseid, $rc]
+     */
+    protected function prepare_for_enrolments_test($target, $additionalcaps = []) {
+        global $CFG, $DB;
+        $this->resetAfterTest(true);
+
+        // Turn off file logging, otherwise it can't delete the file (Windows).
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+
+        $user = $this->getDataGenerator()->create_user();
+        $roleidcat = create_role('Category role', 'dummyrole1', 'dummy role description');
+
+        $course = $this->getDataGenerator()->create_course();
+
+        // Enable instance of self-enrolment plugin (it should already be present) and enrol a student with it.
+        $selfplugin = enrol_get_plugin('self');
+        $selfinstance = $DB->get_record('enrol', array('courseid' => $course->id, 'enrol' => 'self'));
+        $studentrole = $DB->get_record('role', array('shortname' => 'student'));
+        $selfplugin->update_status($selfinstance, ENROL_INSTANCE_ENABLED);
+        $selfplugin->enrol_user($selfinstance, $user->id, $studentrole->id);
+
+        // Give current user capabilities to do backup and restore and assign student role.
+        $categorycontext = context_course::instance($course->id)->get_parent_context();
+
+        $caps = array_merge([
+            'moodle/course:view',
+            'moodle/course:create',
+            'moodle/backup:backupcourse',
+            'moodle/backup:configure',
+            'moodle/backup:backuptargetimport',
+            'moodle/restore:restorecourse',
+            'moodle/role:assign',
+            'moodle/restore:configure',
+        ], $additionalcaps);
+
+        foreach ($caps as $cap) {
+            assign_capability($cap, CAP_ALLOW, $roleidcat, $categorycontext);
+        }
+
+        allow_assign($roleidcat, $studentrole->id);
+        role_assign($roleidcat, $user->id, $categorycontext);
+        accesslib_clear_all_caches_for_unit_testing();
+
+        $this->setUser($user);
+
+        // Do backup with default settings. MODE_IMPORT means it will just
+        // create the directory and not zip it.
+        $bc = new backup_controller(backup::TYPE_1COURSE, $course->id,
+            backup::FORMAT_MOODLE, backup::INTERACTIVE_NO, backup::MODE_SAMESITE,
+            $user->id);
+        $backupid = $bc->get_backupid();
+        $backupbasepath = $bc->get_plan()->get_basepath();
+        $bc->execute_plan();
+        $results = $bc->get_results();
+        $file = $results['backup_destination'];
+        $bc->destroy();
+
+        // Restore the backup immediately.
+
+        // Check if we need to unzip the file because the backup temp dir does not contains backup files.
+        if (!file_exists($backupbasepath . "/moodle_backup.xml")) {
+            $file->extract_to_pathname(get_file_packer('application/vnd.moodle.backup'), $backupbasepath);
+        }
+
+        if ($target == backup::TARGET_NEW_COURSE) {
+            $newcourseid = restore_dbops::create_new_course($course->fullname . '_2',
+                $course->shortname . '_2',
+                $course->category);
+        } else {
+            $newcourse = $this->getDataGenerator()->create_course();
+            $newcourseid = $newcourse->id;
+        }
+        $rc = new restore_controller($backupid, $newcourseid,
+            backup::INTERACTIVE_NO, backup::MODE_SAMESITE, $user->id, $target);
+
+        return [$course, $newcourseid, $rc];
+    }
+
+    /**
+     * Backup a course with enrolment methods and restore it without user data and without enrolment methods
+     */
+    public function test_restore_without_users_without_enrolments() {
+        global $DB;
+
+        list($course, $newcourseid, $rc) = $this->prepare_for_enrolments_test(backup::TARGET_NEW_COURSE);
+
+        // Ensure enrolment methods will not be restored without capability.
+        $this->assertEquals(backup::ENROL_NEVER, $rc->get_plan()->get_setting('enrolments')->get_value());
+        $this->assertEquals(false, $rc->get_plan()->get_setting('users')->get_value());
+
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        // Self-enrolment method was not enabled, users were not restored.
+        $this->assertEmpty($DB->count_records('enrol', ['enrol' => 'self', 'courseid' => $newcourseid,
+            'status' => ENROL_INSTANCE_ENABLED]));
+        $sql = "select ue.id, ue.userid, e.enrol from {user_enrolments} ue
+          join {enrol} e on ue.enrolid = e.id WHERE e.courseid = ?";
+        $enrolments = $DB->get_records_sql($sql, [$newcourseid]);
+        $this->assertEmpty($enrolments);
+    }
+
+    /**
+     * Backup a course with enrolment methods and restore it without user data with enrolment methods
+     */
+    public function test_restore_without_users_with_enrolments() {
+        global $DB;
+
+        list($course, $newcourseid, $rc) = $this->prepare_for_enrolments_test(backup::TARGET_NEW_COURSE,
+            ['moodle/course:enrolconfig']);
+
+        // Ensure enrolment methods will be restored.
+        $this->assertEquals(backup::ENROL_NEVER, $rc->get_plan()->get_setting('enrolments')->get_value());
+        $this->assertEquals(false, $rc->get_plan()->get_setting('users')->get_value());
+        // Set "Include enrolment methods" to "Always" so they can be restored without users.
+        $rc->get_plan()->get_setting('enrolments')->set_value(backup::ENROL_ALWAYS);
+
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        // Self-enrolment method was restored (it is enabled), users were not restored.
+        $enrol = $DB->get_records('enrol', ['enrol' => 'self', 'courseid' => $newcourseid,
+            'status' => ENROL_INSTANCE_ENABLED]);
+        $this->assertNotEmpty($enrol);
+
+        $sql = "select ue.id, ue.userid, e.enrol from {user_enrolments} ue
+            join {enrol} e on ue.enrolid = e.id WHERE e.courseid = ?";
+        $enrolments = $DB->get_records_sql($sql, [$newcourseid]);
+        $this->assertEmpty($enrolments);
+    }
+
+    /**
+     * Backup a course with enrolment methods and restore it with user data and without enrolment methods
+     */
+    public function test_restore_with_users_without_enrolments() {
+        global $DB;
+
+        list($course, $newcourseid, $rc) = $this->prepare_for_enrolments_test(backup::TARGET_NEW_COURSE,
+            ['moodle/backup:userinfo', 'moodle/restore:userinfo']);
+
+        // Ensure enrolment methods will not be restored without capability.
+        $this->assertEquals(backup::ENROL_NEVER, $rc->get_plan()->get_setting('enrolments')->get_value());
+        $this->assertEquals(true, $rc->get_plan()->get_setting('users')->get_value());
+
+        global $qwerty;
+        $qwerty = 1;
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+        $qwerty = 0;
+
+        // Self-enrolment method was not restored, student was restored as manual enrolment.
+        $this->assertEmpty($DB->count_records('enrol', ['enrol' => 'self', 'courseid' => $newcourseid,
+            'status' => ENROL_INSTANCE_ENABLED]));
+
+        $enrol = $DB->get_record('enrol', ['enrol' => 'manual', 'courseid' => $newcourseid]);
+        $this->assertEquals(1, $DB->count_records('user_enrolments', ['enrolid' => $enrol->id]));
+    }
+
+    /**
+     * Backup a course with enrolment methods and restore it with user data with enrolment methods
+     */
+    public function test_restore_with_users_with_enrolments() {
+        global $DB;
+
+        list($course, $newcourseid, $rc) = $this->prepare_for_enrolments_test(backup::TARGET_NEW_COURSE,
+            ['moodle/backup:userinfo', 'moodle/restore:userinfo', 'moodle/course:enrolconfig']);
+
+        // Ensure enrolment methods will be restored.
+        $this->assertEquals(backup::ENROL_WITHUSERS, $rc->get_plan()->get_setting('enrolments')->get_value());
+        $this->assertEquals(true, $rc->get_plan()->get_setting('users')->get_value());
+
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        // Self-enrolment method was restored (it is enabled), student was restored.
+        $enrol = $DB->get_records('enrol', ['enrol' => 'self', 'courseid' => $newcourseid,
+            'status' => ENROL_INSTANCE_ENABLED]);
+        $this->assertNotEmpty($enrol);
+
+        $sql = "select ue.id, ue.userid, e.enrol from {user_enrolments} ue
+            join {enrol} e on ue.enrolid = e.id WHERE e.courseid = ?";
+        $enrolments = $DB->get_records_sql($sql, [$newcourseid]);
+        $this->assertEquals(1, count($enrolments));
+        $enrolment = reset($enrolments);
+        $this->assertEquals('self', $enrolment->enrol);
+    }
+
+    /**
+     * Backup a course with enrolment methods and restore it with user data with enrolment methods merging into another course
+     */
+    public function test_restore_with_users_with_enrolments_merging() {
+        global $DB;
+
+        list($course, $newcourseid, $rc) = $this->prepare_for_enrolments_test(backup::TARGET_EXISTING_ADDING,
+            ['moodle/backup:userinfo', 'moodle/restore:userinfo', 'moodle/course:enrolconfig']);
+
+        // Ensure enrolment methods will be restored.
+        $this->assertEquals(backup::ENROL_WITHUSERS, $rc->get_plan()->get_setting('enrolments')->get_value());
+        $this->assertEquals(true, $rc->get_plan()->get_setting('users')->get_value());
+
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        // User was restored with self-enrolment method.
+        $enrol = $DB->get_records('enrol', ['enrol' => 'self', 'courseid' => $newcourseid,
+            'status' => ENROL_INSTANCE_ENABLED]);
+        $this->assertNotEmpty($enrol);
+
+        $sql = "select ue.id, ue.userid, e.enrol from {user_enrolments} ue
+            join {enrol} e on ue.enrolid = e.id WHERE e.courseid = ?";
+        $enrolments = $DB->get_records_sql($sql, [$newcourseid]);
+        $this->assertEquals(1, count($enrolments));
+        $enrolment = reset($enrolments);
+        $this->assertEquals('self', $enrolment->enrol);
+    }
+
+    /**
+     * Backup a course with enrolment methods and restore it with user data with enrolment methods into another course deleting it's contents
+     */
+    public function test_restore_with_users_with_enrolments_deleting() {
+        global $DB;
+
+        list($course, $newcourseid, $rc) = $this->prepare_for_enrolments_test(backup::TARGET_EXISTING_DELETING,
+            ['moodle/backup:userinfo', 'moodle/restore:userinfo', 'moodle/course:enrolconfig']);
+
+        // Ensure enrolment methods will be restored.
+        $this->assertEquals(backup::ENROL_WITHUSERS, $rc->get_plan()->get_setting('enrolments')->get_value());
+        $this->assertEquals(true, $rc->get_plan()->get_setting('users')->get_value());
+
+        $this->assertTrue($rc->execute_precheck());
+        $rc->execute_plan();
+        $rc->destroy();
+
+        // Self-enrolment method was restored (it is enabled), student was restored.
+        $enrol = $DB->get_records('enrol', ['enrol' => 'self', 'courseid' => $newcourseid,
+            'status' => ENROL_INSTANCE_ENABLED]);
+        $this->assertNotEmpty($enrol);
+
+        $sql = "select ue.id, ue.userid, e.enrol from {user_enrolments} ue
+            join {enrol} e on ue.enrolid = e.id WHERE e.courseid = ?";
+        $enrolments = $DB->get_records_sql($sql, [$newcourseid]);
+        $this->assertEquals(1, count($enrolments));
+        $enrolment = reset($enrolments);
+        $this->assertEquals('self', $enrolment->enrol);
+    }
 }
index fd13cbb..867d7cc 100644 (file)
@@ -203,6 +203,20 @@ abstract class restore_check {
                 $overwritesetting = $restore_controller->get_plan()->get_setting('overwrite_conf');
                 $overwritesetting->set_status(base_setting::LOCKED_BY_PERMISSION);
             }
+
+            // Ensure the user has the capability to manage enrolment methods. If not we want to unset and lock
+            // the setting so that they cannot change it.
+            $hasmanageenrolcap = has_capability('moodle/course:enrolconfig', $coursectx, $userid);
+            if (!$hasmanageenrolcap) {
+                if ($restore_controller->get_plan()->setting_exists('enrolments')) {
+                    $enrolsetting = $restore_controller->get_plan()->get_setting('enrolments');
+                    if ($enrolsetting->get_value() != backup::ENROL_NEVER) {
+                        $enrolsetting->set_status(base_setting::NOT_LOCKED); // In case it was locked earlier.
+                        $enrolsetting->set_value(backup::ENROL_NEVER);
+                    }
+                    $enrolsetting->set_status(base_setting::LOCKED_BY_PERMISSION);
+                }
+            }
         }
 
         return true;
index 3015d22..0cb151b 100644 (file)
@@ -519,7 +519,7 @@ class core_backup_renderer extends plugin_renderer_base {
             $method = 'get';
         }
         $url->param('sesskey', sesskey());
-        $button = new single_button($url, get_string('continue'), $method);
+        $button = new single_button($url, get_string('continue'), $method, true);
         $button->class = 'continuebutton';
         return $this->render($button);
     }
index aa3f0b0..0342ee6 100644 (file)
@@ -52,6 +52,7 @@ class block_activity_results_edit_form extends block_edit_form {
         $sql = 'SELECT id, itemname FROM {grade_items} WHERE courseid = ? and itemtype = ? and (gradetype = ? or gradetype = ?)';
         $params = array($this->page->course->id, 'mod', GRADE_TYPE_VALUE, GRADE_TYPE_SCALE);
         $activities = $DB->get_records_sql_menu($sql, $params);
+        core_collator::asort($activities);
 
         if (empty($activities)) {
             $mform->addElement('static', 'noactivitieswarning', get_string('config_select_activity', 'block_activity_results'),
index be906de..c08835d 100644 (file)
@@ -56,11 +56,6 @@ class block_login extends block_base {
         // TODO: now that we have multiauth it is hard to find out if there is a way to change password
         $forgot = $wwwroot . '/login/forgot_password.php';
 
-        if (!empty($CFG->loginpasswordautocomplete)) {
-            $autocomplete = 'autocomplete="off"';
-        } else {
-            $autocomplete = '';
-        }
 
         $username = get_moodle_cookie();
 
@@ -75,14 +70,14 @@ class block_login extends block_base {
                 $strusername = get_string('usernameemail');
             }
 
-            $this->content->text .= "\n".'<form class="loginform" id="login" method="post" action="'.get_login_url().'" '.$autocomplete.'>';
+            $this->content->text .= "\n".'<form class="loginform" id="login" method="post" action="'.get_login_url().'">';
 
             $this->content->text .= '<div class="form-group"><label for="login_username">'.$strusername.'</label>';
             $this->content->text .= '<input type="text" name="username" id="login_username" class="form-control" value="'.s($username).'" /></div>';
 
             $this->content->text .= '<div class="form-group"><label for="login_password">'.get_string('password').'</label>';
 
-            $this->content->text .= '<input type="password" name="password" id="login_password" class="form-control" value="" '.$autocomplete.' /></div>';
+            $this->content->text .= '<input type="password" name="password" id="login_password" class="form-control" value="" /></div>';
 
             if (isset($CFG->rememberusername) and $CFG->rememberusername == 2) {
                 $checked = $username ? 'checked="checked"' : '';
index e41e68c..47e9e78 100644 (file)
@@ -33,6 +33,9 @@
     /** The maximum time in seconds that cron will wait between attempts to retry failing RSS feeds. */
     const CLIENT_MAX_SKIPTIME = 43200; // 60 * 60 * 12 seconds.
 
+    /** @var bool track whether any of the output feeds have recorded failures */
+    private $hasfailedfeeds = false;
+
     function init() {
         $this->title = get_string('pluginname', 'block_rss_client');
     }
@@ -59,6 +62,7 @@
      * @return block_rss_client\output\footer|null The renderable footer or null if none should be displayed.
      */
     protected function get_footer($feedrecords) {
+        global $PAGE;
         $footer = null;
 
         if ($this->config->block_rss_client_show_channel_link) {
             }
         }
 
+        if ($this->hasfailedfeeds) {
+            if (has_any_capability(['block/rss_client:manageownfeeds', 'block/rss_client:manageanyfeeds'], $this->context)) {
+                if ($footer === null) {
+                    $footer = new block_rss_client\output\footer();
+                }
+                $manageurl = new moodle_url('/blocks/rss_client/managefeeds.php', ['courseid' => $PAGE->course->id]);
+                $footer->set_failed($manageurl);
+            }
+        }
+
         return $footer;
     }
 
         global $CFG;
         require_once($CFG->libdir.'/simplepie/moodle_simplepie.php');
 
+        if ($feedrecord->skipuntil) {
+            // Last attempt to gather this feed via cron failed - do not try to fetch it now.
+            $this->hasfailedfeeds = true;
+            return null;
+        }
+
         $simplepiefeed = new moodle_simplepie($feedrecord->url);
 
         if(isset($CFG->block_rss_client_timeout)){
index 3da2039..c864df3 100644 (file)
@@ -40,16 +40,23 @@ class footer implements \renderable, \templatable {
     /**
      * The link provided in the RSS channel
      *
-     * @var \moodle_url
+     * @var \moodle_url|null
      */
     protected $channelurl;
 
+    /**
+     * Link to manage feeds, only provided if a feed has failed.
+     *
+     * @var \moodle_url|null
+     */
+    protected $manageurl = null;
+
     /**
      * Constructor
      *
-     * @param \moodle_url $channelurl The link provided in the RSS channel
+     * @param \moodle_url $channelurl (optional) The link provided in the RSS channel
      */
-    public function __construct(\moodle_url $channelurl) {
+    public function __construct($channelurl = null) {
         $this->channelurl = $channelurl;
     }
 
@@ -65,6 +72,16 @@ class footer implements \renderable, \templatable {
         return $this;
     }
 
+    /**
+     * Record the fact that there is at least one failed feed (and the URL for viewing
+     * these failed feeds).
+     *
+     * @param \moodle_url $manageurl the URL to link to for more information
+     */
+    public function set_failed(\moodle_url $manageurl) {
+        $this->manageurl = $manageurl;
+    }
+
     /**
      * Get the channel url
      *
@@ -84,6 +101,10 @@ class footer implements \renderable, \templatable {
     public function export_for_template(\renderer_base $output) {
         $data = new \stdClass();
         $data->channellink = clean_param($this->channelurl, PARAM_URL);
+        if ($this->manageurl) {
+            $data->hasfailedfeeds = true;
+            $data->manageurl = clean_param($this->manageurl, PARAM_URL);
+        }
 
         return $data;
     }
index 3fbbdc7..e606979 100644 (file)
@@ -44,6 +44,8 @@ $string['editnewsfeeds'] = 'Edit news feeds';
 $string['editrssblock'] = 'Edit RSS headline block';
 $string['enableautodiscovery'] = 'Enable auto-discovery of feeds?';
 $string['enableautodiscovery_help'] = 'If enabled, feeds on web pages are found automatically. For example, if http://docs.moodle.org is entered, then http://docs.moodle.org/en/index.php?title=Special:RecentChanges&feed=rss would be found.';
+$string['failedfeed'] = 'Feed failed to download - will retry after {$a}';
+$string['failedfeeds'] = 'One or more RSS feeds have failed';
 $string['feed'] = 'Feed';
 $string['feedadded'] = 'News feed added';
 $string['feeddeleted'] = 'News feed deleted';
index 475337c..85cf983 100644 (file)
@@ -114,6 +114,13 @@ foreach($feeds as $feed) {
     $feedinfo = '<div class="title">' . $viewlink . '</div>' .
         '<div class="url">' . html_writer::link($feed->url, $feed->url) .'</div>' .
         '<div class="description">' . $feed->description . '</div>';
+    if ($feed->skipuntil) {
+        $skipuntil = userdate($feed->skipuntil, get_string('strftimedatetime', 'langconfig'));
+        $skipmsg = get_string('failedfeed', 'block_rss_client', $skipuntil);
+        $notification = new \core\output\notification($skipmsg, 'error');
+        $notification->set_show_closebutton(false);
+        $feedinfo .= $OUTPUT->render($notification);
+    }
 
     $editurl = new moodle_url('/blocks/rss_client/editfeed.php?rssid=' . $feed->id . $extraparams);
     $editaction = $OUTPUT->action_icon($editurl, new pix_icon('t/edit', get_string('edit')));
index b1aa373..dd5d0fe 100644 (file)
         "channellink": "https://www.example.com/feeds/rss"
     }
 }}
-<a href="{{{channellink}}}">{{#str}} clientchannellink, block_rss_client {{/str}}</a>
+{{#channellink}}
+    <a href="{{{channellink}}}">{{#str}} clientchannellink, block_rss_client {{/str}}</a>
+    {{#hasfailedfeeds}}<br>{{/hasfailedfeeds}}
+{{/channellink}}
+{{#hasfailedfeeds}}
+    <a href="{{{manageurl}}}">{{#str}} failedfeeds, block_rss_client {{/str}}</a>
+{{/hasfailedfeeds}}
\ No newline at end of file
index 26bbf00..60b20a3 100644 (file)
@@ -59,7 +59,7 @@ class block_site_main_menu extends block_list {
             if (!empty($modinfo->sections[0])) {
                 foreach($modinfo->sections[0] as $cmid) {
                     $cm = $modinfo->cms[$cmid];
-                    if (!$cm->uservisible) {
+                    if (!$cm->uservisible || !$cm->is_visible_on_course_page()) {
                         continue;
                     }
 
@@ -72,7 +72,7 @@ class block_site_main_menu extends block_list {
                     if (!empty($cm->url)) {
                         $content = html_writer::div($courserenderer->course_section_cm_name($cm), 'activity');
                     } else {
-                        $content = $cm->get_formatted_content(array('overflowdiv' => true, 'noclean' => true));
+                        $content = $courserenderer->course_section_cm_text($cm);
                     }
 
                     $this->content->items[] = $indent . html_writer::div($content, 'main-menu-content');
@@ -103,7 +103,7 @@ class block_site_main_menu extends block_list {
         if (!empty($modinfo->sections[0])) {
             foreach ($modinfo->sections[0] as $modnumber) {
                 $mod = $modinfo->cms[$modnumber];
-                if (!$mod->uservisible) {
+                if (!$mod->uservisible || !$mod->is_visible_on_course_page()) {
                     continue;
                 }
                 if (!$ismoving) {
@@ -138,7 +138,7 @@ class block_site_main_menu extends block_list {
                         $indent = '';
                     }
                     if (!$mod->url) {
-                        $content = $mod->get_formatted_content(array('overflowdiv' => true, 'noclean' => true));
+                        $content = $courserenderer->course_section_cm_text($mod);
                     } else {
                         $content = html_writer::div($courserenderer->course_section_cm_name($mod), ' activity');
                     }
index 62054a0..a9cfb61 100644 (file)
@@ -1,4 +1,4 @@
-@block @block_main_menu
+@block @block_site_main_menu
 Feature: Add URL to main menu block
   In order to add helpful resources for students
   As a admin
diff --git a/blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php b/blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php
new file mode 100644 (file)
index 0000000..f523116
--- /dev/null
@@ -0,0 +1,157 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Behat steps definitions for block site main menu
+ *
+ * @package    block_site_main_menu
+ * @category   test
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
+
+require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
+
+use Behat\Mink\Exception\ExpectationException as ExpectationException,
+    Behat\Mink\Exception\DriverException as DriverException,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+
+/**
+ * Behat steps definitions for block site main menu
+ *
+ * @package    block_site_main_menu
+ * @category   test
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_block_site_main_menu extends behat_base {
+
+    /**
+     * Returns the DOM node of the activity in the site menu block
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param string $activityname The activity name
+     * @return NodeElement
+     */
+    protected function get_site_menu_activity_node($activityname) {
+        $activityname = behat_context_helper::escape($activityname);
+        $xpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_site_main_menu ')]//li[contains(., $activityname)]";
+
+        return $this->find('xpath', $xpath);
+    }
+
+    /**
+     * Checks that the specified activity's action menu contains an item.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in site main menu block should have "(?P<icon_name_string>(?:[^"]|\\")*)" editing icon$/
+     * @param string $activityname
+     * @param string $iconname
+     */
+    public function activity_in_site_main_menu_block_should_have_editing_icon($activityname, $iconname) {
+        $activitynode = $this->get_site_menu_activity_node($activityname);
+
+        $notfoundexception = new ExpectationException('"' . $activityname . '" doesn\'t have a "' .
+            $iconname . '" editing icon', $this->getSession());
+        $this->find('named_partial', array('link', $iconname), $notfoundexception, $activitynode);
+    }
+
+    /**
+     * Checks that the specified activity's action menu contains an item.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in site main menu block should not have "(?P<icon_name_string>(?:[^"]|\\")*)" editing icon$/
+     * @param string $activityname
+     * @param string $iconname
+     */
+    public function activity_in_site_main_menu_block_should_not_have_editing_icon($activityname, $iconname) {
+        $activitynode = $this->get_site_menu_activity_node($activityname);
+
+        try {
+            $this->find('named_partial', array('link', $iconname), false, $activitynode);
+            throw new ExpectationException('"' . $activityname . '" has a "' . $iconname .
+                '" editing icon when it should not', $this->getSession());
+        } catch (ElementNotFoundException $e) {
+            // This is good, the menu item should not be there.
+        }
+    }
+
+    /**
+     * Clicks on the specified element of the activity. You should be in the course page with editing mode turned on.
+     *
+     * @Given /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" in the "(?P<activity_name_string>(?:[^"]|\\")*)" activity in site main menu block$/
+     * @param string $element
+     * @param string $selectortype
+     * @param string $activityname
+     */
+    public function i_click_on_in_the_activity_in_site_main_menu_block($element, $selectortype, $activityname) {
+        $element = $this->get_site_menu_activity_element($element, $selectortype, $activityname);
+        $element->click();
+    }
+
+    /**
+     * Clicks on the specified element inside the activity container.
+     *
+     * @throws ElementNotFoundException
+     * @param string $element
+     * @param string $selectortype
+     * @param string $activityname
+     * @return NodeElement
+     */
+    protected function get_site_menu_activity_element($element, $selectortype, $activityname) {
+        $activitynode = $this->get_site_menu_activity_node($activityname);
+
+        // Transforming to Behat selector/locator.
+        list($selector, $locator) = $this->transform_selector($selectortype, $element);
+        $exception = new ElementNotFoundException($this->getSession(), '"' . $element . '" "' .
+            $selectortype . '" in "' . $activityname . '" ');
+
+        return $this->find($selector, $locator, $exception, $activitynode);
+    }
+
+    /**
+     * Checks that the specified activity is hidden.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in site main menu block should be hidden$/
+     * @param string $activityname
+     */
+    public function activity_in_site_main_menu_block_should_be_hidden($activityname) {
+        $this->get_site_menu_activity_element("a.dimmed", "css_element", $activityname);
+    }
+
+    /**
+     * Checks that the specified activity is hidden.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in site main menu block should be available but hidden from course page$/
+     * @param string $activityname
+     */
+    public function activity_in_site_main_menu_block_should_be_available_but_hidden_from_course_page($activityname) {
+        $this->get_site_menu_activity_element("a.stealth", "css_element", $activityname);
+    }
+
+    /**
+     * Opens an activity actions menu if it is not already opened.
+     *
+     * @Given /^I open "(?P<activity_name_string>(?:[^"]|\\")*)" actions menu in site main menu block$/
+     * @throws DriverException The step is not available when Javascript is disabled
+     * @param string $activityname
+     */
+    public function i_open_actions_menu_in_site_main_menu_block($activityname) {
+        $activityname = behat_context_helper::escape($activityname);
+        $xpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_site_main_menu ')]//li[contains(., $activityname)]";
+        $this->execute('behat_action_menu::i_open_the_action_menu_in', [$xpath, 'xpath_element']);
+    }
+}
index 4799495..6cb07df 100644 (file)
@@ -1,4 +1,4 @@
-@block @block_main_menu
+@block @block_site_main_menu
 Feature: Edit activities in main menu block
   In order to use main menu block
   As an admin
@@ -12,7 +12,7 @@ Feature: Edit activities in main menu block
     And I add the "Main menu" block
     When I add a "Forum" to section "0" and I fill the form with:
       | Forum name | My forum name |
-    And I click on "Edit title" "link" in the "//*[contains(@class,'block_site_main_menu')]//li[contains(.,'My forum name')]" "xpath_element"
+    And I click on "Edit title" "link" in the "My forum name" activity in site main menu block
     And I set the field "New name for activity My forum name" to "New forum name"
     And I press key "13" in the field "New name for activity My forum name"
     Then I should not see "My forum name"
@@ -20,3 +20,49 @@ Feature: Edit activities in main menu block
     And I follow "New forum name"
     And I should not see "My forum name"
     And I should see "New forum name"
+
+  @javascript
+  Scenario: Activities in main menu block can be made available but not visible on a course page
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | allowstealth | 1 |
+    And I am on site homepage
+    And I navigate to "Turn editing on" node in "Front page settings"
+    And I add the "Main menu" block
+    When I add a "Forum" to section "0" and I fill the form with:
+      | Forum name | Visible forum |
+    When I add a "Forum" to section "0" and I fill the form with:
+      | Forum name | My forum name |
+    And "My forum name" activity in site main menu block should have "Hide" editing icon
+    And "My forum name" activity in site main menu block should not have "Show" editing icon
+    And "My forum name" activity in site main menu block should not have "Make available" editing icon
+    And "My forum name" activity in site main menu block should not have "Make unavailable" editing icon
+    And I open "My forum name" actions menu in site main menu block
+    And I click on "Hide" "link" in the "My forum name" activity in site main menu block
+    And "My forum name" activity in site main menu block should be hidden
+    And "My forum name" activity in site main menu block should not have "Hide" editing icon
+    And "My forum name" activity in site main menu block should have "Show" editing icon
+    And "My forum name" activity in site main menu block should have "Make available" editing icon
+    And "My forum name" activity in site main menu block should not have "Make unavailable" editing icon
+    And I open "My forum name" actions menu in site main menu block
+    And I click on "Make available" "link" in the "My forum name" activity in site main menu block
+    And "My forum name" activity in site main menu block should be available but hidden from course page
+    And "My forum name" activity in site main menu block should not have "Hide" editing icon
+    And "My forum name" activity in site main menu block should have "Show" editing icon
+    And "My forum name" activity in site main menu block should not have "Make available" editing icon
+    And "My forum name" activity in site main menu block should have "Make unavailable" editing icon
+    # Make sure that "Visible" dropdown in the edit menu has three options.
+    And I open "My forum name" actions menu in site main menu block
+    And I click on "Edit settings" "link" in the "My forum name" activity in site main menu block
+    And I expand all fieldsets
+    And the "Visible" select box should contain "Show"
+    And the "Visible" select box should contain "Hidden from students"
+    And the "Visible" select box should not contain "Hide"
+    And the field "Visible" matches value "Available but not displayed on course page"
+    And I press "Save and return to course"
+    And "My forum name" activity in site main menu block should be available but hidden from course page
+    And I navigate to "Turn editing off" node in "Front page settings"
+    And "My forum name" activity in site main menu block should be available but hidden from course page
+    And I log out
+    And I should not see "My forum name" in the "Main menu" "block"
+    And I should see "Visible forum" in the "Main menu" "block"
index 2553413..63e9cbb 100644 (file)
@@ -61,12 +61,12 @@ class block_social_activities extends block_list {
             if (!empty($modinfo->sections[0])) {
                 foreach($modinfo->sections[0] as $cmid) {
                     $cm = $modinfo->cms[$cmid];
-                    if (!$cm->uservisible) {
+                    if (!$cm->uservisible || !$cm->is_visible_on_course_page()) {
                         continue;
                     }
 
                     if (!$cm->url) {
-                        $content = $cm->get_formatted_content(array('overflowdiv' => true, 'noclean' => true));
+                        $content = $courserenderer->course_section_cm_text($cm);
                         $this->content->items[] = $content;
                         $this->content->icons[] = '';
                     } else {
@@ -98,7 +98,7 @@ class block_social_activities extends block_list {
         if (!empty($modinfo->sections[0])) {
             foreach ($modinfo->sections[0] as $modnumber) {
                 $mod = $modinfo->cms[$modnumber];
-                if (!$mod->uservisible) {
+                if (!$mod->uservisible || !$mod->is_visible_on_course_page()) {
                     continue;
                 }
                 if (!$ismoving) {
@@ -128,7 +128,7 @@ class block_social_activities extends block_list {
                         $this->content->icons[] = '';
                     }
                     if (!$mod->url) {
-                        $content = $mod->get_formatted_content(array('overflowdiv' => true, 'noclean' => true));
+                        $content = $courserenderer->course_section_cm_text($mod);
                         $this->content->items[] = $content . $editbuttons;
                         $this->content->icons[] = '';
                     } else {
diff --git a/blocks/social_activities/tests/behat/behat_block_social_activities.php b/blocks/social_activities/tests/behat/behat_block_social_activities.php
new file mode 100644 (file)
index 0000000..2fe5526
--- /dev/null
@@ -0,0 +1,157 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Behat steps definitions for block social activities
+ *
+ * @package    block_social_activities
+ * @category   test
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
+
+require_once(__DIR__ . '/../../../../lib/behat/behat_base.php');
+
+use Behat\Mink\Exception\ExpectationException as ExpectationException,
+    Behat\Mink\Exception\DriverException as DriverException,
+    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+
+/**
+ * Behat steps definitions for block social activities
+ *
+ * @package    block_social_activities
+ * @category   test
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_block_social_activities extends behat_base {
+
+    /**
+     * Returns the DOM node of the activity in the social activities block
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param string $activityname The activity name
+     * @return NodeElement
+     */
+    protected function get_social_block_activity_node($activityname) {
+        $activityname = behat_context_helper::escape($activityname);
+        $xpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_social_activities ')]//li[contains(., $activityname)]";
+
+        return $this->find('xpath', $xpath);
+    }
+
+    /**
+     * Checks that the specified activity's action menu contains an item.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should have "(?P<icon_name_string>(?:[^"]|\\")*)" editing icon$/
+     * @param string $activityname
+     * @param string $iconname
+     */
+    public function activity_in_social_activities_block_should_have_editing_icon($activityname, $iconname) {
+        $activitynode = $this->get_social_block_activity_node($activityname);
+
+        $notfoundexception = new ExpectationException('"' . $activityname . '" doesn\'t have a "' .
+            $iconname . '" editing icon', $this->getSession());
+        $this->find('named_partial', array('link', $iconname), $notfoundexception, $activitynode);
+    }
+
+    /**
+     * Checks that the specified activity's action menu contains an item.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should not have "(?P<icon_name_string>(?:[^"]|\\")*)" editing icon$/
+     * @param string $activityname
+     * @param string $iconname
+     */
+    public function activity_in_social_activities_block_should_not_have_editing_icon($activityname, $iconname) {
+        $activitynode = $this->get_social_block_activity_node($activityname);
+
+        try {
+            $this->find('named_partial', array('link', $iconname), false, $activitynode);
+            throw new ExpectationException('"' . $activityname . '" has a "' . $iconname .
+                '" editing icon when it should not', $this->getSession());
+        } catch (ElementNotFoundException $e) {
+            // This is good, the menu item should not be there.
+        }
+    }
+
+    /**
+     * Clicks on the specified element of the activity. You should be in the course page with editing mode turned on.
+     *
+     * @Given /^I click on "(?P<element_string>(?:[^"]|\\")*)" "(?P<selector_string>(?:[^"]|\\")*)" in the "(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block$/
+     * @param string $element
+     * @param string $selectortype
+     * @param string $activityname
+     */
+    public function i_click_on_in_the_activity_in_social_activities_block($element, $selectortype, $activityname) {
+        $element = $this->get_social_block_activity_element($element, $selectortype, $activityname);
+        $element->click();
+    }
+
+    /**
+     * Clicks on the specified element inside the activity container.
+     *
+     * @throws ElementNotFoundException
+     * @param string $element
+     * @param string $selectortype
+     * @param string $activityname
+     * @return NodeElement
+     */
+    protected function get_social_block_activity_element($element, $selectortype, $activityname) {
+        $activitynode = $this->get_social_block_activity_node($activityname);
+
+        // Transforming to Behat selector/locator.
+        list($selector, $locator) = $this->transform_selector($selectortype, $element);
+        $exception = new ElementNotFoundException($this->getSession(), '"' . $element . '" "' .
+            $selectortype . '" in "' . $activityname . '" ');
+
+        return $this->find($selector, $locator, $exception, $activitynode);
+    }
+
+    /**
+     * Checks that the specified activity is hidden.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should be hidden$/
+     * @param string $activityname
+     */
+    public function activity_in_social_activities_block_should_be_hidden($activityname) {
+        $this->get_social_block_activity_element("a.dimmed", "css_element", $activityname);
+    }
+
+    /**
+     * Checks that the specified activity is hidden.
+     *
+     * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should be available but hidden from course page$/
+     * @param string $activityname
+     */
+    public function activity_in_social_activities_block_should_be_available_but_hidden_from_course_page($activityname) {
+        $this->get_social_block_activity_element("a.stealth", "css_element", $activityname);
+    }
+
+    /**
+     * Opens an activity actions menu if it is not already opened.
+     *
+     * @Given /^I open "(?P<activity_name_string>(?:[^"]|\\")*)" actions menu in social activities block$/
+     * @throws DriverException The step is not available when Javascript is disabled
+     * @param string $activityname
+     */
+    public function i_open_actions_menu_in_social_activities_block($activityname) {
+        $activityname = behat_context_helper::escape($activityname);
+        $xpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_social_activities ')]//li[contains(., $activityname)]";
+        $this->execute('behat_action_menu::i_open_the_action_menu_in', [$xpath, 'xpath_element']);
+    }
+}
index 07b58e3..a29b066 100644 (file)
@@ -1,27 +1,31 @@
-@block @block_social_activities
+@block @block_social_activities @format_social
 Feature: Edit activities in social activities block
   In order to use social activities block
   As a teacher
   I need to add and edit activities there
 
-  @javascript
-  Scenario: Edit name of acitivity in-place in social activities block
+  Background:
     Given the following "courses" exist:
       | fullname | shortname | format |
       | Course 1 | C1        | social |
     And the following "users" exist:
       | username | firstname | lastname |
       | user1 | User | One |
+      | student1 | Student | One |
     And the following "course enrolments" exist:
       | user | course | role |
       | user1 | C1 | editingteacher |
+      | student1 | C1 | student |
+
+  @javascript
+  Scenario: Edit name of acitivity in-place in social activities block
     Given I log in as "user1"
     And I follow "Course 1"
     And I turn editing mode on
     And I set the field "Add an activity to section 'section 0'" to "Forum"
     And I set the field "Forum name" to "My forum name"
     And I press "Save and return to course"
-    And I click on "Edit title" "link" in the "//*[contains(@class,'block_social_activities')]//li[contains(.,'My forum name')]" "xpath_element"
+    And I click on "Edit title" "link" in the "My forum name" activity in social activities block
     And I set the field "New name for activity My forum name" to "New forum name"
     And I press key "13" in the field "New name for activity My forum name"
     Then I should not see "My forum name" in the "Social activities" "block"
@@ -29,3 +33,56 @@ Feature: Edit activities in social activities block
     And I follow "New forum name"
     And I should not see "My forum name"
     And I should see "New forum name"
+
+  @javascript
+  Scenario: Activities in social activities block can be made available but not visible on a course page
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | allowstealth | 1 |
+    And I log out
+    And I log in as "user1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Recent activity" block
+    And I set the field "Add an activity to section 'section 0'" to "Forum"
+    And I set the field "Forum name" to "My forum name"
+    And I press "Save and return to course"
+    And "My forum name" activity in social activities block should have "Hide" editing icon
+    And "My forum name" activity in social activities block should not have "Show" editing icon
+    And "My forum name" activity in social activities block should not have "Make available" editing icon
+    And "My forum name" activity in social activities block should not have "Make unavailable" editing icon
+    And I wait until the page is ready
+    And I open "My forum name" actions menu in social activities block
+    And I click on "Hide" "link" in the "My forum name" activity in social activities block
+    And "My forum name" activity in social activities block should be hidden
+    And "My forum name" activity in social activities block should not have "Hide" editing icon
+    And "My forum name" activity in social activities block should have "Show" editing icon
+    And "My forum name" activity in social activities block should have "Make available" editing icon
+    And "My forum name" activity in social activities block should not have "Make unavailable" editing icon
+    And I open "My forum name" actions menu in social activities block
+    And I click on "Make available" "link" in the "My forum name" activity in social activities block
+    And "My forum name" activity in social activities block should be available but hidden from course page
+    And "My forum name" activity in social activities block should not have "Hide" editing icon
+    And "My forum name" activity in social activities block should have "Show" editing icon
+    And "My forum name" activity in social activities block should not have "Make available" editing icon
+    And "My forum name" activity in social activities block should have "Make unavailable" editing icon
+    # Make sure that "Visible" dropdown in the edit menu has three options.
+    And I open "My forum name" actions menu in social activities block
+    And I click on "Edit settings" "link" in the "My forum name" activity in social activities block
+    And I expand all fieldsets
+    And the "Visible" select box should contain "Show"
+    And the "Visible" select box should contain "Hidden from students"
+    And the "Visible" select box should not contain "Hide"
+    And the field "Visible" matches value "Available but not displayed on course page"
+    And I press "Save and return to course"
+    And "My forum name" activity in social activities block should be available but hidden from course page
+    And I turn editing mode off
+    And "My forum name" activity in social activities block should be available but hidden from course page
+    And I log out
+    # Student will not see the module on the course page but can access it from other reports and blocks:
+    And I log in as "student1"
+    And I follow "Course 1"
+    And I should not see "My forum name" in the "Social activities" "block"
+    And I click on "My forum name" "link" in the "Recent activity" "block"
+    And I should see "My forum name" in the ".breadcrumb" "css_element"
+    And I log out
index 9ae2895..bdcc7bc 100644 (file)
@@ -57,6 +57,12 @@ M.core_comment = {
                         this.view(0);
                         return false;
                     }, this);
+                    // Also handle space/enter key.
+                    handle.on('key', function(e) {
+                        e.preventDefault();
+                        this.view(0);
+                        return false;
+                    }, '13,32', this);
                 }
                 scope.toggle_textarea(false);
             },
@@ -335,6 +341,7 @@ M.core_comment = {
                 );
             },
             view: function(page) {
+                var commenttoggler = Y.one('#comment-link-' + this.client_id);
                 var container = Y.one('#comment-ctrl-'+this.client_id);
                 var ta = Y.one('#dlg-content-'+this.client_id);
                 var img = Y.one('#comment-img-'+this.client_id);
@@ -351,6 +358,9 @@ M.core_comment = {
                     if (img) {
                         img.set('src', M.util.image_url('t/expanded', 'core'));
                     }
+                    if (commenttoggler) {
+                        commenttoggler.setAttribute('aria-expanded', 'true');
+                    }
                 } else {
                     // hide
                     container.setStyle('display', 'none');
@@ -364,6 +374,9 @@ M.core_comment = {
                     if (ta) {
                         ta.set('value','');
                     }
+                    if (commenttoggler) {
+                        commenttoggler.setAttribute('aria-expanded', 'false');
+                    }
                 }
                 if (ta) {
                     //toggle_textarea.apply(ta, [false]);
index 42b3d42..8bc0197 100644 (file)
@@ -460,7 +460,13 @@ class comment {
                 } else {
                     $collapsedimage= 't/collapsed';
                 }
-                $html .= html_writer::start_tag('a', array('class' => 'comment-link', 'id' => 'comment-link-'.$this->cid, 'href' => '#'));
+                $html .= html_writer::start_tag('a', array(
+                    'class' => 'comment-link',
+                    'id' => 'comment-link-'.$this->cid,
+                    'href' => '#',
+                    'role' => 'button',
+                    'aria-expanded' => 'false')
+                );
                 $html .= html_writer::empty_tag('img', array('id' => 'comment-img-'.$this->cid, 'src' => $OUTPUT->pix_url($collapsedimage), 'alt' => $this->linktext, 'title' => $this->linktext));
                 $html .= html_writer::tag('span', $this->linktext.' '.$countstring, array('id' => 'comment-link-text-'.$this->cid));
                 $html .= html_writer::end_tag('a');
index 5d19955..3f94122 100644 (file)
@@ -42,7 +42,8 @@ Feature: Restrict activity availability through date conditions
     And I am on site homepage
     And I follow "Course 1"
     Then I should see "Available from 31 December 2037"
-    And "Test assignment 1" activity should be hidden
+    And "Test assignment 1" activity should be dimmed
+    And "Test assignment 1" "link" should not exist
     And I log out
 
   @javascript
index 040bf16..6c970e8 100644 (file)
@@ -44,7 +44,8 @@ Feature: Restrict activity availability through grade conditions
     And I am on site homepage
     And I follow "Course 1"
     Then I should see "Not available unless: You achieve a required score in Grade assignment"
-    And "Test page name" activity should be hidden
+    And "Test page name" activity should be dimmed
+    And "Test page name" "link" should not exist
     And I follow "Grade assignment"
     And I press "Add submission"
     And I set the following fields to these values:
index 9c795b6..92dfb41 100644 (file)
@@ -7,6 +7,7 @@
     "require-dev": {
         "phpunit/phpunit": "5.5.*",
         "phpunit/dbUnit": "1.4.*",
-        "moodlehq/behat-extension": "3.33.1"
+        "moodlehq/behat-extension": "3.33.1",
+        "mikey179/vfsStream": "^1.6"
     }
 }
index c757473..1fca2b9 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
         "This file is @generated automatically"
     ],
-    "content-hash": "f4cfcd74744fbbced495458ea82fd314",
+    "content-hash": "751fc6623b264b33856167019b391053",
     "packages": [],
     "packages-dev": [
         {
             ],
             "time": "2015-06-15T20:19:33+00:00"
         },
+        {
+            "name": "mikey179/vfsStream",
+            "version": "v1.6.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/mikey179/vfsStream.git",
+                "reference": "0247f57b2245e8ad2e689d7cee754b45fbabd592"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/mikey179/vfsStream/zipball/0247f57b2245e8ad2e689d7cee754b45fbabd592",
+                "reference": "0247f57b2245e8ad2e689d7cee754b45fbabd592",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "~4.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.6.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-0": {
+                    "org\\bovigo\\vfs\\": "src/main/php"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Frank Kleine",
+                    "homepage": "http://frankkleine.de/",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Virtual file system to mock the real file system in unit tests.",
+            "homepage": "http://vfs.bovigo.org/",
+            "time": "2016-07-18T14:02:57+00:00"
+        },
         {
             "name": "moodlehq/behat-extension",
             "version": "v3.33.1",
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.5.5",
+            "version": "1.6.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "399c1f9781e222f6eb6cc238796f5200d1b7f108"
+                "reference": "5a5a9fc8025a08d8919be87d6884d5a92520cefe"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/399c1f9781e222f6eb6cc238796f5200d1b7f108",
-                "reference": "399c1f9781e222f6eb6cc238796f5200d1b7f108",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/5a5a9fc8025a08d8919be87d6884d5a92520cefe",
+                "reference": "5a5a9fc8025a08d8919be87d6884d5a92520cefe",
                 "shasum": ""
             },
             "require": {
                 "object",
                 "object graph"
             ],
-            "time": "2016-10-31T17:19:45+00:00"
+            "time": "2017-01-26T22:05:40+00:00"
         },
         {
             "name": "phpdocumentor/reflection-common",
         },
         {
             "name": "phpunit/php-code-coverage",
-            "version": "4.0.4",
+            "version": "4.0.5",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "c14196e64a78570034afd0b7a9f3757ba71c2a0a"
+                "reference": "c19cfc7cbb0e9338d8c469c7eedecc2a428b0971"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c14196e64a78570034afd0b7a9f3757ba71c2a0a",
-                "reference": "c14196e64a78570034afd0b7a9f3757ba71c2a0a",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c19cfc7cbb0e9338d8c469c7eedecc2a428b0971",
+                "reference": "c19cfc7cbb0e9338d8c469c7eedecc2a428b0971",
                 "shasum": ""
             },
             "require": {
                 "testing",
                 "xunit"
             ],
-            "time": "2016-12-20T15:22:42+00:00"
+            "time": "2017-01-20T15:06:43+00:00"
         },
         {
             "name": "phpunit/php-file-iterator",
         },
         {
             "name": "sebastian/comparator",
-            "version": "1.2.2",
+            "version": "1.2.4",
             "source": {
                 "type": "git",
                 "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "6a1ed12e8b2409076ab22e3897126211ff8b1f7f"
+                "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a1ed12e8b2409076ab22e3897126211ff8b1f7f",
-                "reference": "6a1ed12e8b2409076ab22e3897126211ff8b1f7f",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2b7424b55f5047b47ac6e5ccb20b2aea4011d9be",
+                "reference": "2b7424b55f5047b47ac6e5ccb20b2aea4011d9be",
                 "shasum": ""
             },
             "require": {
                 "compare",
                 "equality"
             ],
-            "time": "2016-11-19T09:18:40+00:00"
+            "time": "2017-01-29T09:50:25+00:00"
         },
         {
             "name": "sebastian/diff",
diff --git a/course/amd/build/actions.min.js b/course/amd/build/actions.min.js
new file mode 100644 (file)
index 0000000..797a71e
Binary files /dev/null and b/course/amd/build/actions.min.js differ
diff --git a/course/amd/src/actions.js b/course/amd/src/actions.js
new file mode 100644 (file)
index 0000000..5c4a72c
--- /dev/null
@@ -0,0 +1,594 @@
+// 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/>.
+
+/**
+ * Various actions on modules and sections in the editing mode - hiding, duplicating, deleting, etc.
+ *
+ * @module     core_course/actions
+ * @package    core
+ * @copyright  2016 Marina Glancy
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.3
+ */
+define(['jquery', 'core/ajax', 'core/templates', 'core/notification', 'core/str', 'core/url', 'core/yui'],
+    function($, ajax, templates, notification, str, url, Y) {
+        var CSS = {
+            EDITINPROGRESS: 'editinprogress',
+            SECTIONDRAGGABLE: 'sectiondraggable',
+            EDITINGMOVE: 'editing_move'
+        };
+        var SELECTOR = {
+            ACTIVITYLI: 'li.activity',
+            ACTIONAREA: '.actions',
+            ACTIVITYACTION: 'a.cm-edit-action',
+            MENU: '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',
+            TOGGLE: '.toggle-display,.dropdown-toggle',
+            SECTIONLI: 'li.section',
+            SECTIONACTIONMENU: '.section_action_menu'
+        };
+
+        Y.use('moodle-course-coursebase', function() {
+            var courseformatselector = M.course.format.get_section_selector();
+            if (courseformatselector) {
+                SELECTOR.SECTIONLI = courseformatselector;
+            }
+        });
+
+        /**
+         * Wrapper for Y.Moodle.core_course.util.cm.getId
+         *
+         * @param {JQuery} element
+         * @returns {Integer}
+         */
+        var getModuleId = function(element) {
+            var id;
+            Y.use('moodle-course-util', function(Y) {
+                id = Y.Moodle.core_course.util.cm.getId(Y.Node(element.get(0)));
+            });
+            return id;
+        };
+
+        /**
+         * Wrapper for Y.Moodle.core_course.util.cm.getName
+         *
+         * @param {JQuery} element
+         * @returns {String}
+         */
+        var getModuleName = function(element) {
+            var name;
+            Y.use('moodle-course-util', function(Y) {
+                name = Y.Moodle.core_course.util.cm.getName(Y.Node(element.get(0)));
+            });
+            return name;
+        };
+
+        /**
+         * Wrapper for M.util.add_spinner for an activity
+         *
+         * @param {JQuery} activity
+         * @returns {Node}
+         */
+        var addActivitySpinner = function(activity) {
+            activity.addClass(CSS.EDITINPROGRESS);
+            var actionarea = activity.find(SELECTOR.ACTIONAREA).get(0);
+            if (actionarea) {
+                var spinner = M.util.add_spinner(Y, Y.Node(actionarea));
+                spinner.show();
+                return spinner;
+            }
+            return null;
+        };
+
+        /**
+         * Wrapper for M.util.add_spinner for a section
+         *
+         * @param {JQuery} sectionelement
+         * @returns {Node}
+         */
+        var addSectionSpinner = function(sectionelement) {
+            sectionelement.addClass(CSS.EDITINPROGRESS);
+            var actionarea = sectionelement.find(SELECTOR.SECTIONACTIONMENU).get(0);
+            if (actionarea) {
+                var spinner = M.util.add_spinner(Y, Y.Node(actionarea));
+                spinner.show();
+                return spinner;
+            }
+            return null;
+        };
+
+        /**
+         * Wrapper for M.util.add_lightbox
+         *
+         * @param {JQuery} sectionelement
+         * @returns {Node}
+         */
+        var addSectionLightbox = function(sectionelement) {
+            var lightbox = M.util.add_lightbox(Y, Y.Node(sectionelement.get(0)));
+            lightbox.show();
+            return lightbox;
+        };
+
+        /**
+         * Removes the spinner element
+         *
+         * @param {JQuery} element
+         * @param {Node} spinner
+         * @param {Number} delay
+         */
+        var removeSpinner = function(element, spinner, delay) {
+            window.setTimeout(function() {
+                element.removeClass(CSS.EDITINPROGRESS);
+                if (spinner) {
+                    spinner.hide();
+                }
+            }, delay);
+        };
+
+        /**
+         * Removes the lightbox element
+         *
+         * @param {Node} lightbox lighbox YUI element returned by addSectionLightbox
+         * @param {Number} delay
+         */
+        var removeLightbox = function(lightbox, delay) {
+            if (lightbox) {
+                window.setTimeout(function() {
+                    lightbox.hide();
+                }, delay);
+            }
+        };
+
+        /**
+         * Initialise action menu for the element (section or module)
+         *
+         * @param {String} elementid CSS id attribute of the element
+         * @param {Boolean} openmenu whether to open menu - this can be used when re-initiating menu after indent action was pressed
+         */
+        var initActionMenu = function(elementid, openmenu) {
+            // Initialise action menu in the new activity.
+            Y.use('moodle-course-coursebase', function() {
+                M.course.coursebase.invoke_function('setup_for_resource', '#' + elementid);
+            });
+            if (M.core.actionmenu && M.core.actionmenu.newDOMNode) {
+                M.core.actionmenu.newDOMNode(Y.one('#' + elementid));
+            }
+            // Open action menu if the original element had data-keepopen.
+            if (openmenu) {
+                // We must use YUI click simulate here so the toggle works in Clean theme. This toggle is not
+                // needed in Boost because we use standard bootstrapbase action menu.
+                var toggle = Y.one('#' + elementid + ' ' + SELECTOR.MENU).one(SELECTOR.TOGGLE);
+                if (toggle && toggle.simulate) {
+                    toggle.simulate('click');
+                }
+            }
+        };
+
+        /**
+         * Returns focus to the element that was clicked or "Edit" link if element is no longer visible.
+         *
+         * @param {String} elementId CSS id attribute of the element
+         * @param {String} action data-action property of the element that was clicked
+         */
+        var focusActionItem = function(elementId, action) {
+            var mainelement = $('#' + elementId);
+            var selector = '[data-action=' + action + ']';
+            if (action === 'groupsseparate' || action === 'groupsvisible' || action === 'groupsnone') {
+                // New element will have different data-action.
+                selector = '[data-action=groupsseparate],[data-action=groupsvisible],[data-action=groupsnone]';
+            }
+            if (mainelement.find(selector).is(':visible')) {
+                mainelement.find(selector).focus();
+            } else {
+                // Element not visible, focus the "Edit" link.
+                mainelement.find(SELECTOR.MENU).find(SELECTOR.TOGGLE).focus();
+            }
+        };
+
+        /**
+         * Find next <a> after the element
+         *
+         * @param {JQuery} mainElement element that is about to be deleted
+         * @returns {JQuery}
+         */
+        var findNextFocusable = function(mainElement) {
+            var tabables = $("a:visible");
+            var isInside = false, foundElement = null;
+            tabables.each(function() {
+                if ($.contains(mainElement[0], this)) {
+                    isInside = true;
+                } else if (isInside) {
+                    foundElement = this;
+                    return false; // Returning false in .each() is equivalent to "break;" inside the loop in php.
+                }
+            });
+            return foundElement;
+        };
+
+        /**
+         * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)
+         *
+         * @param {JQuery} moduleElement activity element we perform action on
+         * @param {Number} cmid
+         * @param {JQuery} target the element (menu item) that was clicked
+         */
+        var editModule = function(moduleElement, cmid, target) {
+            var keepopen = target.attr('data-keepopen'),
+                    action = target.attr('data-action');
+            var spinner = addActivitySpinner(moduleElement);
+            var promises = ajax.call([{
+                methodname: 'core_course_edit_module',
+                args: {id: cmid,
+                    action: action,
+                    sectionreturn: target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : 0
+                }
+            }], true);
+
+            var lightbox;
+            if (action === 'duplicate') {
+                lightbox = addSectionLightbox(target.closest(SELECTOR.SECTIONLI));
+            }
+            $.when.apply($, promises)
+                .done(function(data) {
+                    var elementToFocus = findNextFocusable(moduleElement);
+                    moduleElement.replaceWith(data);
+                    // Initialise action menu for activity(ies) added as a result of this.
+                    $('<div>' + data + '</div>').find(SELECTOR.ACTIVITYLI).each(function(index) {
+                        initActionMenu($(this).attr('id'), keepopen);
+                        if (index === 0) {
+                            focusActionItem($(this).attr('id'), action);
+                            elementToFocus = null;
+                        }
+                    });
+                    // In case of activity deletion focus the next focusable element.
+                    if (elementToFocus) {
+                        elementToFocus.focus();
+                    }
+                    // Remove spinner and lightbox with a delay.
+                    removeSpinner(moduleElement, spinner, 400);
+                    removeLightbox(lightbox, 400);
+                    // Trigger event that can be observed by course formats.
+                    moduleElement.trigger($.Event('coursemoduleedited', {ajaxreturn: data, action: action}));
+                }).fail(function(ex) {
+                    // Remove spinner and lightbox.
+                    removeSpinner(moduleElement, spinner);
+                    removeLightbox(lightbox);
+                    // Trigger event that can be observed by course formats.
+                    var e = $.Event('coursemoduleeditfailed', {exception: ex, action: action});
+                    moduleElement.trigger(e);
+                    if (!e.isDefaultPrevented()) {
+                        notification.exception(ex);
+                    }
+                });
+        };
+
+        /**
+         * Requests html for the module via WS core_course_get_module and updates the module on the course page
+         *
+         * Used after d&d of the module to another section
+         *
+         * @param {JQuery} activityElement
+         * @param {Number} cmid
+         * @param {Number} sectionreturn
+         */
+        var refreshModule = function(activityElement, cmid, sectionreturn) {
+            var spinner = addActivitySpinner(activityElement);
+            var promises = ajax.call([{
+                methodname: 'core_course_get_module',
+                args: {id: cmid, sectionreturn: sectionreturn}
+            }], true);
+
+            $.when.apply($, promises)
+                .done(function(data) {
+                    removeSpinner(activityElement, spinner, 400);
+                    replaceActivityHtmlWith(data);
+                }).fail(function() {
+                    removeSpinner(activityElement, spinner);
+                });
+        };
+
+        /**
+         * Displays the delete confirmation to delete a module
+         *
+         * @param {JQuery} mainelement activity element we perform action on
+         * @param {function} onconfirm function to execute on confirm
+         */
+        var confirmDeleteModule = function(mainelement, onconfirm) {
+            var modtypename = mainelement.attr('class').match(/modtype_([^\s]*)/)[1];
+            var modulename = getModuleName(mainelement);
+
+            str.get_string('pluginname', modtypename).done(function(pluginname) {
+                var plugindata = {
+                    type: pluginname,
+                    name: modulename
+                };
+                str.get_strings([
+                    {key: 'confirm'},
+                    {key: modulename === null ? 'deletechecktype' : 'deletechecktypename', param: plugindata},
+                    {key: 'yes'},
+                    {key: 'no'}
+                ]).done(function(s) {
+                        notification.confirm(s[0], s[1], s[2], s[3], onconfirm);
+                    }
+                );
+            });
+        };
+
+        /**
+         * Displays the delete confirmation to delete a section
+         *
+         * @param {String} message confirmation message
+         * @param {function} onconfirm function to execute on confirm
+         */
+        var confirmEditSection = function(message, onconfirm) {
+            str.get_strings([
+                {key: 'confirm'}, // TODO link text
+                {key: 'yes'},
+                {key: 'no'}
+            ]).done(function(s) {
+                    notification.confirm(s[0], message, s[1], s[2], onconfirm);
+                }
+            );
+        };
+
+        /**
+         * Replaces an action menu item with another one (for example Show->Hide, Set marker->Remove marker)
+         *
+         * @param {JQuery} actionitem
+         * @param {String} image new image name ("i/show", "i/hide", etc.)
+         * @param {String} stringname new string for the action menu item
+         * @param {String} stringcomponent
+         * @param {String} titlestr string for "title" attribute (if different from stringname)
+         * @param {String} titlecomponent
+         * @param {String} newaction new value for data-action attribute of the link
+         */
+        var replaceActionItem = function(actionitem, image, stringname,
+                                           stringcomponent, titlestr, titlecomponent, newaction) {
+            actionitem.find('img').attr('src', url.imageUrl(image, 'core'));
+            str.get_string(stringname, stringcomponent).done(function(newstring) {
+                actionitem.find('span.menu-action-text').html(newstring);
+                actionitem.attr('title', newstring);
+            });
+            if (titlestr) {
+                str.get_string(titlestr, titlecomponent).done(function(newtitle) {
+                    actionitem.attr('title', newtitle);
+                });
+            }
+            actionitem.attr('data-action', newaction);
+        };
+
+        /**
+         * Default post-processing for section AJAX edit actions.
+         *
+         * This can be overridden in course formats by listening to event coursesectionedited:
+         *
+         * $('body').on('coursesectionedited', 'li.section', function(e) {
+         *     var action = e.action,
+         *         sectionElement = $(e.target),
+         *         data = e.ajaxreturn;
+         *     // ... Do some processing here.
+         *     e.preventDefault(); // Prevent default handler.
+         * });
+         *
+         * @param {JQuery} sectionElement
+         * @param {JQuery} actionItem
+         * @param {Object} data
+         * @param {String} courseformat
+         */
+        var defaultEditSectionHandler = function(sectionElement, actionItem, data, courseformat) {
+            var action = actionItem.attr('data-action');
+            if (action === 'hide' || action === 'show') {
+                if (action === 'hide') {
+                    sectionElement.addClass('hidden');
+                    replaceActionItem(actionItem, 'i/show',
+                        'showfromothers', 'format_' + courseformat, null, null, 'show');
+                } else {
+                    sectionElement.removeClass('hidden');
+                    replaceActionItem(actionItem, 'i/hide',
+                        'hidefromothers', 'format_' + courseformat, null, null, 'hide');
+                }
+                // Replace the modules with new html (that indicates that they are now hidden or not hidden).
+                if (data.modules !== undefined) {
+                    for (var i in data.modules) {
+                        replaceActivityHtmlWith(data.modules[i]);
+                    }
+                }
+                // Replace the section availability information.
+                if (data.section_availability !== undefined) {
+                    sectionElement.find('.section_availability').first().replaceWith(data.section_availability);
+                }
+            } else if (action === 'setmarker') {
+                var oldmarker = $(SELECTOR.SECTIONLI + '.current'),
+                    oldActionItem = oldmarker.find(SELECTOR.SECTIONACTIONMENU + ' ' + 'a[data-action=removemarker]');
+                oldmarker.removeClass('current');
+                replaceActionItem(oldActionItem, 'i/marker',
+                    'highlight', 'core', 'markthistopic', 'core', 'setmarker');
+                sectionElement.addClass('current');
+                replaceActionItem(actionItem, 'i/marked',
+                    'highlightoff', 'core', 'markedthistopic', 'core', 'removemarker');
+            } else if (action === 'removemarker') {
+                sectionElement.removeClass('current');
+                replaceActionItem(actionItem, 'i/marker',
+                    'highlight', 'core', 'markthistopic', 'core', 'setmarker');
+            }
+        };
+
+        /**
+         * Replaces the course module with the new html (used to update module after it was edited or its visibility was changed).
+         *
+         * @param {String} activityHTML
+         */
+        var replaceActivityHtmlWith = function(activityHTML) {
+            $('<div>' + activityHTML + '</div>').find(SELECTOR.ACTIVITYLI).each(function() {
+                // Extract id from the new activity html.
+                var id = $(this).attr('id');
+                // Find the existing element with the same id and replace its contents with new html.
+                $(SELECTOR.ACTIVITYLI + '#' + id).replaceWith(activityHTML);
+                // Initialise action menu.
+                initActionMenu(id, false);
+            });
+        };
+
+        /**
+         * Performs an action on a module (moving, deleting, duplicating, hiding, etc.)
+         *
+         * @param {JQuery} sectionElement section element we perform action on
+         * @param {Nunmber} sectionid
+         * @param {JQuery} target the element (menu item) that was clicked
+         * @param {String} courseformat
+         */
+        var editSection = function(sectionElement, sectionid, target, courseformat) {
+            var action = target.attr('data-action'),
+                sectionreturn = target.attr('data-sectionreturn') ? target.attr('data-sectionreturn') : 0;
+            var spinner = addSectionSpinner(sectionElement);
+            var promises = ajax.call([{
+                methodname: 'core_course_edit_section',
+                args: {id: sectionid, action: action, sectionreturn: sectionreturn}
+            }], true);
+
+            var lightbox = addSectionLightbox(sectionElement);
+            $.when.apply($, promises)
+                .done(function(dataencoded) {
+                    var data = $.parseJSON(dataencoded);
+                    removeSpinner(sectionElement, spinner);
+                    removeLightbox(lightbox);
+                    sectionElement.find(SELECTOR.SECTIONACTIONMENU).find(SELECTOR.TOGGLE).focus();
+                    // Trigger event that can be observed by course formats.
+                    var e = $.Event('coursesectionedited', {ajaxreturn: data, action: action});
+                    sectionElement.trigger(e);
+                    if (!e.isDefaultPrevented()) {
+                        defaultEditSectionHandler(sectionElement, target, data, courseformat);
+                    }
+                }).fail(function(ex) {
+                    // Remove spinner and lightbox.
+                    removeSpinner(sectionElement, spinner);
+                    removeLightbox(lightbox);
+                    // Trigger event that can be observed by course formats.
+                    var e = $.Event('coursesectioneditfailed', {exception: ex, action: action});
+                    sectionElement.trigger(e);
+                    if (!e.isDefaultPrevented()) {
+                        notification.exception(ex);
+                    }
+                });
+        };
+
+        // Register a function to be executed after D&D of an activity.
+        Y.use('moodle-course-coursebase', function() {
+            M.course.coursebase.register_module({
+                // Ignore camelcase eslint rule for the next line because it is an expected name of the callback.
+                // eslint-disable-next-line camelcase
+                set_visibility_resource_ui: function(args) {
+                    var mainelement = $(args.element.getDOMNode());
+                    var cmid = getModuleId(mainelement);
+                    if (cmid) {
+                        var sectionreturn = mainelement.find('.' + CSS.EDITINGMOVE).attr('data-sectionreturn');
+                        refreshModule(mainelement, cmid, sectionreturn);
+                    }
+                }
+            });
+        });
+
+        return /** @alias module:core_course/actions */ {
+
+            /**
+             * Initialises course page
+             *
+             * @method init
+             * @param {String} courseformat name of the current course format (for fetching strings)
+             */
+            initCoursePage: function(courseformat) {
+
+                // Add a handler for course module actions.
+                $('body').on('click keypress', SELECTOR.ACTIVITYLI + ' ' +
+                        SELECTOR.ACTIVITYACTION + '[data-action]', function(e) {
+                    if (e.type === 'keypress' && e.keyCode !== 13) {
+                        return;
+                    }
+                    var actionItem = $(this),
+                        moduleElement = actionItem.closest(SELECTOR.ACTIVITYLI),
+                        action = actionItem.attr('data-action'),
+                        moduleId = getModuleId(moduleElement);
+                    switch (action) {
+                        case 'moveleft':
+                        case 'moveright':
+                        case 'delete':
+                        case 'duplicate':
+                        case 'hide':
+                        case 'stealth':
+                        case 'show':
+                        case 'groupsseparate':
+                        case 'groupsvisible':
+                        case 'groupsnone':
+                            break;
+                        default:
+                            // Nothing to do here!
+                            return;
+                    }
+                    if (!moduleId) {
+                        return;
+                    }
+                    e.preventDefault();
+                    if (action === 'delete') {
+                        // Deleting requires confirmation.
+                        confirmDeleteModule(moduleElement, function() {
+                            editModule(moduleElement, moduleId, actionItem);
+                        });
+                    } else {
+                        editModule(moduleElement, moduleId, actionItem);
+                    }
+                });
+
+                // Add a handler for section show/hide actions.
+                $('body').on('click keypress', SELECTOR.SECTIONLI + ' ' +
+                            SELECTOR.SECTIONACTIONMENU + '[data-sectionid] ' +
+                            'a[data-action]', function(e) {
+                    if (e.type === 'keypress' && e.keyCode !== 13) {
+                        return;
+                    }
+                    var actionItem = $(this),
+                        sectionElement = actionItem.closest(SELECTOR.SECTIONLI),
+                        sectionId = actionItem.closest(SELECTOR.SECTIONACTIONMENU).attr('data-sectionid');
+                    e.preventDefault();
+                    if (actionItem.attr('data-confirm')) {
+                        // Action requires confirmation.
+                        confirmEditSection(actionItem.attr('data-confirm'), function() {
+                            editSection(sectionElement, sectionId, actionItem, courseformat);
+                        });
+                    } else {
+                        editSection(sectionElement, sectionId, actionItem, courseformat);
+                    }
+                });
+            },
+
+            /**
+             * Replaces a section action menu item with another one (for example Show->Hide, Set marker->Remove marker)
+             *
+             * This method can be used by course formats in their listener to the coursesectionedited event
+             *
+             * @param {JQuery} sectionelement
+             * @param {String} selector CSS selector inside the section element, for example "a[data-action=show]"
+             * @param {String} image new image name ("i/show", "i/hide", etc.)
+             * @param {String} stringname new string for the action menu item
+             * @param {String} stringcomponent
+             * @param {String} titlestr string for "title" attribute (if different from stringname)
+             * @param {String} titlecomponent
+             * @param {String} newaction new value for data-action attribute of the link
+             */
+            replaceSectionActionItem: function(sectionelement, selector, image, stringname,
+                                                    stringcomponent, titlestr, titlecomponent, newaction) {
+                var actionitem = sectionelement.find(SELECTOR.SECTIONACTIONMENU + ' ' + selector);
+                replaceActionItem(actionitem, image, stringname, stringcomponent, titlestr, titlecomponent, newaction);
+            }
+        };
+    });
\ No newline at end of file
index 9f0afc4..8701f0b 100644 (file)
@@ -422,7 +422,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                     'resortcategoriesby',
                     'name',
                     false,
-                    array('aria-label' => get_string('selectcategorysortby'))
+                    array('aria-label' => get_string('selectcategorysortby'), 'class' => 'm-t-1')
                 )
             );
             $form .= html_writer::div(
@@ -441,15 +441,16 @@ class core_course_management_renderer extends plugin_renderer_base {
                     'resortcoursesby',
                     'fullname',
                     false,
-                    array('aria-label' => get_string('selectcoursesortby'))
+                    array('aria-label' => get_string('selectcoursesortby'), 'class' => 'm-t-1')
                 )
             );
-            $form .= html_writer::empty_tag('input', array('type' => 'submit', 'name' => 'bulksort', 'value' => get_string('sort')));
+            $form .= html_writer::empty_tag('input', array('type' => 'submit', 'name' => 'bulksort',
+                'value' => get_string('sort'), 'class' => 'btn btn-secondary m-y-1'));
             $form .= html_writer::end_div();
 
-            $html .= html_writer::start_div('detail-pair row yui3-g');
-            $html .= html_writer::div(html_writer::span(get_string('sorting')), 'pair-key span3 yui3-u-1-4');
-            $html .= html_writer::div($form, 'pair-value span9 yui3-u-3-4');
+            $html .= html_writer::start_div('detail-pair row yui3-g m-y-1');
+            $html .= html_writer::div(html_writer::span(get_string('sorting')), 'pair-key span3 col-md-3 yui3-u-1-4');
+            $html .= html_writer::div($form, 'pair-value span9 col-md-9 yui3-u-3-4');
             $html .= html_writer::end_div();
         }
         if (coursecat::can_change_parent_any()) {
@@ -463,9 +464,10 @@ class core_course_management_renderer extends plugin_renderer_base {
                 'movecategoriesto',
                 '',
                 array('' => 'choosedots'),
-                array('aria-labelledby' => 'moveselectedcategoriesto')
+                array('aria-labelledby' => 'moveselectedcategoriesto', 'class' => 'm-r-1')
             );
-            $submit = array('type' => 'submit', 'name' => 'bulkmovecategories', 'value' => get_string('move'));
+            $submit = array('type' => 'submit', 'name' => 'bulkmovecategories', 'value' => get_string('move'),
+                'class' => 'btn btn-secondary');
             $html .= $this->detail_pair(
                 html_writer::span(get_string('moveselectedcategoriesto'), '', array('id' => 'moveselectedcategoriesto')),
                 $select . html_writer::empty_tag('input', $submit)
@@ -780,9 +782,10 @@ class core_course_management_renderer extends plugin_renderer_base {
                 'movecoursesto',
                 '',
                 array('' => 'choosedots'),
-                array('aria-labelledby' => 'moveselectedcoursesto')
+                array('aria-labelledby' => 'moveselectedcoursesto', 'class' => 'm-r-1')
             );
-            $submit = array('type' => 'submit', 'name' => 'bulkmovecourses', 'value' => get_string('move'));
+            $submit = array('type' => 'submit', 'name' => 'bulkmovecourses', 'value' => get_string('move'),
+                'class' => 'btn btn-secondary');
             $html .= $this->detail_pair(
                 html_writer::span(get_string('moveselectedcoursesto'), '', array('id' => 'moveselectedcoursesto')),
                 $select . html_writer::empty_tag('input', $submit)
@@ -808,7 +811,8 @@ class core_course_management_renderer extends plugin_renderer_base {
             array('' => 'choosedots'),
             array('aria-labelledby' => 'moveselectedcoursesto')
         );
-        $submit = array('type' => 'submit', 'name' => 'bulkmovecourses', 'value' => get_string('move'));
+        $submit = array('type' => 'submit', 'name' => 'bulkmovecourses', 'value' => get_string('move'),
+            'class' => 'btn btn-secondary');
         $html .= $this->detail_pair(
             html_writer::span(get_string('moveselectedcoursesto'), '', array('id' => 'moveselectedcoursesto')),
             $select . html_writer::empty_tag('input', $submit)
@@ -847,8 +851,8 @@ class core_course_management_renderer extends plugin_renderer_base {
      */
     protected function detail_pair($key, $value, $class ='') {
         $html = html_writer::start_div('detail-pair row yui3-g '.preg_replace('#[^a-zA-Z0-9_\-]#', '-', $class));
-        $html .= html_writer::div(html_writer::span($key), 'pair-key span3 yui3-u-1-4');
-        $html .= html_writer::div(html_writer::span($value), 'pair-value span9 yui3-u-3-4');
+        $html .= html_writer::div(html_writer::span($key), 'pair-key span3 col-md-3 yui3-u-1-4');
+        $html .= html_writer::div(html_writer::span($value), 'pair-value span9 col-md-9 m-b-1 yui3-u-3-4');
         $html .= html_writer::end_div();
         return $html;
     }
@@ -946,7 +950,7 @@ class core_course_management_renderer extends plugin_renderer_base {
     public function grid_column_start($size, $id = null, $class = null) {
 
         // Calculate Bootstrap grid sizing.
-        $bootstrapclass = 'span'.$size;
+        $bootstrapclass = 'span'.$size.' col-md-'.$size;
 
         // Calculate YUI grid sizing.
         if ($size === 12) {
@@ -1322,13 +1326,14 @@ class core_course_management_renderer extends plugin_renderer_base {
         $strsearchcourses = get_string("searchcourses");
         $searchurl = new moodle_url('/course/management.php');
 
-        $output = html_writer::start_tag('form', array('id' => $formid, 'action' => $searchurl, 'method' => 'get'));
-        $output .= html_writer::start_tag('fieldset', array('class' => 'coursesearchbox invisiblefieldset'));
-        $output .= html_writer::tag('label', $strsearchcourses.': ', array('for' => $inputid));
-        $output .= html_writer::empty_tag('input', array('type' => 'text', 'id' => $inputid,
-            'size' => $inputsize, 'name' => 'search', 'value' => s($value)));
-        $output .= html_writer::empty_tag('input', array('type' => 'submit',
-            'value' => get_string('go')));
+        $output = html_writer::start_tag('form', array('id' => $formid, 'action' => $searchurl, 'method' => 'get',
+            'class' => 'form-inline'));
+        $output .= html_writer::start_tag('fieldset', array('class' => 'coursesearchbox invisiblefieldset m-y-1'));
+        $output .= html_writer::tag('label', $strsearchcourses, array('for' => $inputid));
+        $output .= html_writer::empty_tag('input', array('type' => 'text', 'id' => $inputid, 'size' => $inputsize,
+            'name' => 'search', 'value' => s($value), 'class' => 'form-control m-x-1'));
+        $output .= html_writer::empty_tag('input', array('type' => 'submit', 'value' => get_string('go'),
+            'class' => 'btn btn-secondary'));
         $output .= html_writer::end_tag('fieldset');
         $output .= html_writer::end_tag('form');
 
index d509033..8b9aeb8 100644 (file)
@@ -270,6 +270,7 @@ class core_course_external extends external_api {
                                             context_module::instance($cm->id));
                         //user that can view hidden module should know about the visibility
                         $module['visible'] = $cm->visible;
+                        $module['visibleoncoursepage'] = $cm->visibleoncoursepage;
 
                         // Availability date (also send to user who can see hidden module).
                         if ($CFG->enableavailability && ($canviewhidden || $canupdatecourse)) {
@@ -342,6 +343,8 @@ class core_course_external extends external_api {
                                     'instance' => new external_value(PARAM_INT, 'instance id', VALUE_OPTIONAL),
                                     'description' => new external_value(PARAM_RAW, 'activity description', VALUE_OPTIONAL),
                                     'visible' => new external_value(PARAM_INT, 'is the module visible', VALUE_OPTIONAL),
+                                    'visibleoncoursepage' => new external_value(PARAM_INT, 'is the module visible on course page',
+                                        VALUE_OPTIONAL),
                                     'modicon' => new external_value(PARAM_URL, 'activity icon url'),
                                     'modname' => new external_value(PARAM_PLUGIN, 'activity module type'),
                                     'modplural' => new external_value(PARAM_TEXT, 'activity module plural name'),
@@ -1052,6 +1055,7 @@ class core_course_external extends external_api {
                                             "blocks" (int) Include course blocks (default to 1 that is equal to yes),
                                             "filters" (int) Include course filters  (default to 1 that is equal to yes),
                                             "users" (int) Include users (default to 0 that is equal to no),
+                                            "enrolments" (int) Include enrolment methods (default to 1 - restore only with users),
                                             "role_assignments" (int) Include role assignments  (default to 0 that is equal to no),
                                             "comments" (int) Include user comments  (default to 0 that is equal to no),
                                             "userscompletion" (int) Include user course completion information  (default to 0 that is equal to no),
@@ -1116,6 +1120,7 @@ class core_course_external extends external_api {
             'blocks' => 1,
             'filters' => 1,
             'users' => 0,
+            'enrolments' => backup::ENROL_WITHUSERS,
             'role_assignments' => 0,
             'comments' => 0,
             'userscompletion' => 0,
@@ -1171,7 +1176,9 @@ class core_course_external extends external_api {
         backup::INTERACTIVE_NO, backup::MODE_SAMESITE, $USER->id);
 
         foreach ($backupsettings as $name => $value) {
-            $bc->get_plan()->get_setting($name)->set_value($value);
+            if ($setting = $bc->get_plan()->get_setting($name)) {
+                $bc->get_plan()->get_setting($name)->set_value($value);
+            }
         }
 
         $backupid       = $bc->get_backupid();
@@ -2558,6 +2565,7 @@ class core_course_external extends external_api {
                         'score' => new external_value(PARAM_INT, 'Score', VALUE_OPTIONAL),
                         'indent' => new external_value(PARAM_INT, 'Indentation', VALUE_OPTIONAL),
                         'visible' => new external_value(PARAM_INT, 'If visible', VALUE_OPTIONAL),
+                        'visibleoncoursepage' => new external_value(PARAM_INT, 'If visible on course page', VALUE_OPTIONAL),
                         'visibleold' => new external_value(PARAM_INT, 'Visible old', VALUE_OPTIONAL),
                         'completiongradeitemnumber' => new external_value(PARAM_INT, 'Completion grade item', VALUE_OPTIONAL),
                         'completionview' => new external_value(PARAM_INT, 'Completion view setting', VALUE_OPTIONAL),
@@ -3235,4 +3243,226 @@ class core_course_external extends external_api {
     public static function get_updates_since_returns() {
         return self::check_updates_returns();
     }
+
+    /**
+     * Parameters for function edit_module()
+     *
+     * @since Moodle 3.3
+     * @return external_function_parameters
+     */
+    public static function edit_module_parameters() {
+        return new external_function_parameters(
+            array(
+                'action' => new external_value(PARAM_ALPHA,
+                    'action: hide, show, stealth, duplicate, delete, moveleft, moveright, group...', VALUE_REQUIRED),
+                'id' => new external_value(PARAM_INT, 'course module id', VALUE_REQUIRED),
+                'sectionreturn' => new external_value(PARAM_INT, 'section to return to', VALUE_DEFAULT, null),
+            ));
+    }
+
+    /**
+     * Performs one of the edit module actions and return new html for AJAX
+     *
+     * Returns html to replace the current module html with, for example:
+     * - empty string for "delete" action,
+     * - two modules html for "duplicate" action
+     * - updated module html for everything else
+     *
+     * Throws exception if operation is not permitted/possible
+     *
+     * @since Moodle 3.3
+     * @param string $action
+     * @param int $id
+     * @param null|int $sectionreturn
+     * @return string
+     */
+    public static function edit_module($action, $id, $sectionreturn = null) {
+        global $PAGE, $DB;
+        // Validate and normalize parameters.
+        $params = self::validate_parameters(self::edit_module_parameters(),
+            array('action' => $action, 'id' => $id, 'sectionreturn' => $sectionreturn));
+        $action = $params['action'];
+        $id = $params['id'];
+        $sectionreturn = $params['sectionreturn'];
+
+        list($course, $cm) = get_course_and_cm_from_cmid($id);
+        $modcontext = context_module::instance($cm->id);
+        $coursecontext = context_course::instance($course->id);
+        self::validate_context($modcontext);
+        $courserenderer = $PAGE->get_renderer('core', 'course');
+        $completioninfo = new completion_info($course);
+
+        switch($action) {
+            case 'hide':
+            case 'show':
+            case 'stealth':
+                require_capability('moodle/course:activityvisibility', $modcontext);
+                $visible = ($action === 'hide') ? 0 : 1;
+                $visibleoncoursepage = ($action === 'stealth') ? 0 : 1;
+                set_coursemodule_visible($id, $visible, $visibleoncoursepage);
+                \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
+                break;
+            case 'duplicate':
+                require_capability('moodle/course:manageactivities', $coursecontext);
+                require_capability('moodle/backup:backuptargetimport', $coursecontext);
+                require_capability('moodle/restore:restoretargetimport', $coursecontext);
+                if (!course_allowed_module($course, $cm->modname)) {
+                    throw new moodle_exception('No permission to create that activity');
+                }
+                if ($newcm = duplicate_module($course, $cm)) {
+                    $cm = get_fast_modinfo($course)->get_cm($id);
+                    $newcm = get_fast_modinfo($course)->get_cm($newcm->id);
+                    return $courserenderer->course_section_cm_list_item($course, $completioninfo, $cm, $sectionreturn) .
+                        $courserenderer->course_section_cm_list_item($course, $completioninfo, $newcm, $sectionreturn);
+                }
+                break;
+            case 'groupsseparate':
+            case 'groupsvisible':
+            case 'groupsnone':
+                require_capability('moodle/course:manageactivities', $modcontext);
+                if ($action === 'groupsseparate') {
+                    $newgroupmode = SEPARATEGROUPS;
+                } else if ($action === 'groupsvisible') {
+                    $newgroupmode = VISIBLEGROUPS;
+                } else {
+                    $newgroupmode = NOGROUPS;
+                }
+                if (set_coursemodule_groupmode($cm->id, $newgroupmode)) {
+                    \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
+                }
+                break;
+            case 'moveleft':
+            case 'moveright':
+                require_capability('moodle/course:manageactivities', $modcontext);
+                $indent = $cm->indent + (($action === 'moveright') ? 1 : -1);
+                if ($cm->indent >= 0) {
+                    $DB->update_record('course_modules', array('id' => $cm->id, 'indent' => $indent));
+                    rebuild_course_cache($cm->course);
+                }
+                break;
+            case 'delete':
+                require_capability('moodle/course:manageactivities', $modcontext);
+                course_delete_module($cm->id, true);
+                return '';
+            default:
+                throw new coding_exception('Unrecognised action');
+        }
+
+        $cm = get_fast_modinfo($course)->get_cm($id);
+        return $courserenderer->course_section_cm_list_item($course, $completioninfo, $cm, $sectionreturn);
+    }
+
+    /**
+     * Return structure for edit_module()
+     *
+     * @since Moodle 3.3
+     * @return external_description
+     */
+    public static function edit_module_returns() {
+        return new external_value(PARAM_RAW, 'html to replace the current module with');
+    }
+
+    /**
+     * Parameters for function get_module()
+     *
+     * @since Moodle 3.3
+     * @return external_function_parameters
+     */
+    public static function get_module_parameters() {
+        return new external_function_parameters(
+            array(
+                'id' => new external_value(PARAM_INT, 'course module id', VALUE_REQUIRED),
+                'sectionreturn' => new external_value(PARAM_INT, 'section to return to', VALUE_DEFAULT, null),
+            ));
+    }
+
+    /**
+     * Returns html for displaying one activity module on course page
+     *
+     * @since Moodle 3.3
+     * @param int $id
+     * @param null|int $sectionreturn
+     * @return string
+     */
+    public static function get_module($id, $sectionreturn = null) {
+        global $PAGE;
+        // Validate and normalize parameters.
+        $params = self::validate_parameters(self::get_module_parameters(),
+            array('id' => $id, 'sectionreturn' => $sectionreturn));
+        $id = $params['id'];
+        $sectionreturn = $params['sectionreturn'];
+
+        // Validate access to the course (note, this is html for the course view page, we don't validate access to the module).
+        list($course, $cm) = get_course_and_cm_from_cmid($id);
+        self::validate_context(context_course::instance($course->id));
+
+        $courserenderer = $PAGE->get_renderer('core', 'course');
+        $completioninfo = new completion_info($course);
+        return $courserenderer->course_section_cm_list_item($course, $completioninfo, $cm, $sectionreturn);
+    }
+
+    /**
+     * Return structure for edit_module()
+     *
+     * @since Moodle 3.3
+     * @return external_description
+     */
+    public static function get_module_returns() {
+        return new external_value(PARAM_RAW, 'html to replace the current module with');
+    }
+
+    /**
+     * Parameters for function edit_section()
+     *
+     * @since Moodle 3.3
+     * @return external_function_parameters
+     */
+    public static function edit_section_parameters() {
+        return new external_function_parameters(
+            array(
+                'action' => new external_value(PARAM_ALPHA, 'action: hide, show, stealth, setmarker, removemarker', VALUE_REQUIRED),
+                'id' => new external_value(PARAM_INT, 'course section id', VALUE_REQUIRED),
+                'sectionreturn' => new external_value(PARAM_INT, 'section to return to', VALUE_DEFAULT, null),
+            ));
+    }
+
+    /**
+     * Performs one of the edit section actions
+     *
+     * @since Moodle 3.3
+     * @param string $action
+     * @param int $id section id
+     * @param int $sectionreturn section to return to
+     * @return string
+     */
+    public static function edit_section($action, $id, $sectionreturn) {
+        global $DB;
+        // Validate and normalize parameters.
+        $params = self::validate_parameters(self::edit_section_parameters(),
+            array('action' => $action, 'id' => $id, 'sectionreturn' => $sectionreturn));
+        $action = $params['action'];
+        $id = $params['id'];
+        $sr = $params['sectionreturn'];
+
+        $section = $DB->get_record('course_sections', array('id' => $id), '*', MUST_EXIST);
+        $coursecontext = context_course::instance($section->course);
+        self::validate_context($coursecontext);
+
+        $rv = course_get_format($section->course)->section_action($section, $action, $sectionreturn);
+        if ($rv) {
+            return json_encode($rv);
+        } else {
+            return null;
+        }
+    }
+
+    /**
+     * Return structure for edit_section()
+     *
+     * @since Moodle 3.3
+     * @return external_description
+     */
+    public static function edit_section_returns() {
+        return new external_value(PARAM_RAW, 'Additional data for javascript (JSON-encoded string)');
+    }
 }
index 3bb9830..0c7a4f2 100644 (file)
@@ -1170,6 +1170,68 @@ abstract class format_base {
         $startdate = $mform->getElementValue($fieldnames['startdate']);
         return $mform->getElement($fieldnames['startdate'])->exportValue($startdate);
     }
+
+    /**
+     * Returns whether this course format allows the activity to
+     * have "triple visibility state" - visible always, hidden on course page but available, hidden.
+     *
+     * @param stdClass|cm_info $cm course module (may be null if we are displaying a form for adding a module)
+     * @param stdClass|section_info $section section where this module is located or will be added to
+     * @return bool
+     */
+    public function allow_stealth_module_visibility($cm, $section) {
+        return false;
+    }
+
+    /**
+     * Callback used in WS core_course_edit_section when teacher performs an AJAX action on a section (show/hide)
+     *
+     * Access to the course is already validated in the WS but the callback has to make sure
+     * that particular action is allowed by checking capabilities
+     *
+     * Course formats should register
+     *
+     * @param stdClass|section_info $section
+     * @param string $action
+     * @param int $sr
+     * @return null|array|stdClass any data for the Javascript post-processor (must be json-encodeable)
+     */
+    public function section_action($section, $action, $sr) {
+        global $PAGE;
+        if (!$this->uses_sections() || !$section->section) {
+            // No section actions are allowed if course format does not support sections.
+            // No actions are allowed on the 0-section by default (overwrite in course format if needed).
+            throw new moodle_exception('sectionactionnotsupported', 'core', null, s($action));
+        }
+
+        $course = $this->get_course();
+        $coursecontext = context_course::instance($course->id);
+        switch($action) {
+            case 'hide':
+            case 'show':
+                require_capability('moodle/course:sectionvisibility', $coursecontext);
+                $visible = ($action === 'hide') ? 0 : 1;
+                course_update_section($course, $section, array('visible' => $visible));
+                break;
+            default:
+                throw new moodle_exception('sectionactionnotsupported', 'core', null, s($action));
+        }
+
+        $modules = [];
+
+        $modinfo = get_fast_modinfo($course);
+        $coursesections = $modinfo->sections;
+        if (array_key_exists($section->section, $coursesections)) {
+            $courserenderer = $PAGE->get_renderer('core', 'course');
+            $completioninfo = new completion_info($course);
+            foreach ($coursesections[$section->section] as $cmid) {
+                $cm = $modinfo->get_cm($cmid);
+                $modules[] = $courserenderer->course_section_cm_list_item($course, $completioninfo, $cm, $sr);
+            }
+        }
+
+        return ['modules' => $modules];
+    }
 }
 
 /**
@@ -1231,4 +1293,16 @@ class format_site extends format_base {
         }
         return $courseformatoptions;
     }
+
+    /**
+     * Returns whether this course format allows the activity to
+     * have "triple visibility state" - visible always, hidden on course page but available, hidden.
+     *
+     * @param stdClass|cm_info $cm course module (may be null if we are displaying a form for adding a module)
+     * @param stdClass|section_info $section section where this module is located or will be added to
+     * @return bool
+     */
+    public function allow_stealth_module_visibility($cm, $section) {
+        return true;
+    }
 }
index c88d14d..c9237eb 100644 (file)
@@ -38,7 +38,7 @@ defined('MOODLE_INTERNAL') || die();
  */
 abstract class format_section_renderer_base extends plugin_renderer_base {
 
-    /** @var contains instance of core course renderer */
+    /** @var core_course_renderer contains instance of core course renderer */
     protected $courserenderer;
 
     /**
@@ -127,7 +127,8 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
                 $menu->add($al);
             }
 
-            $o .= html_writer::div($this->render($menu), 'section_action_menu');
+            $o .= html_writer::div($this->render($menu), 'section_action_menu',
+                array('data-sectionid' => $section->id));
         }
 
         return $o;
@@ -194,7 +195,8 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
             // Only in the non-general sections.
             if (!$section->visible) {
                 $sectionstyle = ' hidden';
-            } else if (course_get_format($course)->is_section_current($section)) {
+            }
+            if (course_get_format($course)->is_section_current($section)) {
                 $sectionstyle = ' current';
             }
         }
@@ -226,13 +228,11 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
         $sectionname = html_writer::tag('span', $this->section_title($section, $course));
         $o.= $this->output->heading($sectionname, 3, 'sectionname' . $classes);
 
-        $o.= html_writer::start_tag('div', array('class' => 'summary'));
-        $o.= $this->format_summary_text($section);
-        $o.= html_writer::end_tag('div');
+        $o .= $this->section_availability($section);
 
-        $context = context_course::instance($course->id);
-        $o .= $this->section_availability_message($section,
-                has_capability('moodle/course:viewhiddensections', $context));
+        $o .= html_writer::start_tag('div', array('class' => 'summary'));
+        $o .= $this->format_summary_text($section);
+        $o .= html_writer::end_tag('div');
 
         return $o;
     }
@@ -305,14 +305,12 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
             return array();
         }
 
+        $sectionreturn = $onsectionpage ? $section->section : null;
+
         $coursecontext = context_course::instance($course->id);
         $isstealth = isset($course->numsections) && ($section->section > $course->numsections);
 
-        if ($onsectionpage) {
-            $baseurl = course_get_url($course, $section->section);
-        } else {
-            $baseurl = course_get_url($course);
-        }
+        $baseurl = course_get_url($course, $sectionreturn);
         $baseurl->param('sesskey', sesskey());
 
         $controls = array();
@@ -325,7 +323,6 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
                 $streditsection = get_string('editsection');
             }
 
-            $sectionreturn = $onsectionpage ? $section->section : 0;
             $controls['edit'] = array(
                 'url'   => new moodle_url('/course/editsection.php', array('id' => $section->id, 'sr' => $sectionreturn)),
                 'icon' => 'i/settings',
@@ -346,7 +343,8 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
                             'icon' => 'i/hide',
                             'name' => $strhidefromothers,
                             'pixattr' => array('class' => '', 'alt' => $strhidefromothers),
-                            'attr' => array('class' => 'icon editing_showhide', 'title' => $strhidefromothers));
+                            'attr' => array('class' => 'icon editing_showhide', 'title' => $strhidefromothers,
+                                'data-sectionreturn' => $sectionreturn, 'data-action' => 'hide'));
                     } else {
                         $strshowfromothers = get_string('showfromothers', 'format_'.$course->format);
                         $url->param('show',  $section->section);
@@ -355,7 +353,8 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
                             'icon' => 'i/show',
                             'name' => $strshowfromothers,
                             'pixattr' => array('class' => '', 'alt' => $strshowfromothers),
-                            'attr' => array('class' => 'icon editing_showhide', 'title' => $strshowfromothers));
+                            'attr' => array('class' => 'icon editing_showhide', 'title' => $strshowfromothers,
+                                'data-sectionreturn' => $sectionreturn, 'data-action' => 'show'));
                     }
                 }
 
@@ -398,14 +397,14 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
                 }
                 $url = new moodle_url('/course/editsection.php', array(
                     'id' => $section->id,
-                    'sr' => $onsectionpage ? $section->section : 0,
+                    'sr' => $sectionreturn,
                     'delete' => 1));
                 $controls['delete'] = array(
                     'url' => $url,
                     'icon' => 'i/delete',
                     'name' => $strdelete,
                     'pixattr' => array('class' => '', 'alt' => $strdelete),
-                    'attr' => array('class' => 'icon delete', 'title' => $strdelete));
+                    'attr' => array('class' => 'icon editing_delete', 'title' => $strdelete));
             }
         }
 
@@ -452,9 +451,7 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
         $o.= html_writer::end_tag('div');
         $o.= $this->section_activity_summary($section, $course, null);
 
-        $context = context_course::instance($course->id);
-        $o .= $this->section_availability_message($section,
-                has_capability('moodle/course:viewhiddensections', $context));
+        $o .= $this->section_availability($section);
 
         $o .= html_writer::end_tag('div');
         $o .= html_writer::end_tag('li');
@@ -548,31 +545,50 @@ abstract class format_section_renderer_base extends plugin_renderer_base {
      * are going to be unavailable etc). This logic is the same as for
      * activities.
      *
-     * @param stdClass $section The course_section entry from DB
+     * @param section_info $section The course_section entry from DB
      * @param bool $canviewhidden True if user can view hidden sections
      * @return string HTML to output
      */
     protected function section_availability_message($section, $canviewhidden) {
         global $CFG;
         $o = '';
-        if (!$section->uservisible) {
-            // Note: We only get to this function if availableinfo is non-empty,
-            // so there is definitely something to print.
-            $formattedinfo = \core_availability\info::format_info(
-                    $section->availableinfo, $section->course);
-            $o .= html_writer::div($formattedinfo, 'availabilityinfo');
-        } else if ($canviewhidden && !empty($CFG->enableavailability) && $section->visible) {
+        if (!$section->visible) {
+            if ($canviewhidden) {
+                $o .= $this->courserenderer->availability_info(get_string('hiddenfromstudents'), 'ishidden');
+            }
+        } else if (!$section->uservisible) {
+            if ($section->availableinfo) {
+                // Note: We only get to this function if availableinfo is non-empty,
+                // so there is definitely something to print.
+                $formattedinfo = \core_availability\info::format_info(
+                        $section->availableinfo, $section->course);
+                $o .= $this->courserenderer->availability_info($formattedinfo);
+            }
+        } else if ($canviewhidden && !empty($CFG->enableavailability)) {
+            // Check if there is an availability restriction.
             $ci = new \core_availability\info_section($section);
             $fullinfo = $ci->get_full_information();
             if ($fullinfo) {
                 $formattedinfo = \core_availability\info::format_info(
                         $fullinfo, $section->course);
-                $o .= html_writer::div($formattedinfo, 'availabilityinfo');
+                $o .= $this->courserenderer->availability_info($formattedinfo);
             }
         }
         return $o;
     }
 
+    /**
+     * Displays availability information for the section (hidden, not available unles, etc.)
+     *
+     * @param section_info $section
+     * @return string
+     */
+    public function section_availability($section) {
+        $context = context_course::instance($section->course);
+        $canviewhidden = has_capability('moodle/course:viewhiddensections', $context);
+        return html_writer::div($this->section_availability_message($section, $canviewhidden), 'section_availability');
+    }
+
     /**
      * Show if something is on on the course clipboard (moving around)
      *
index d405e7c..c44e0f6 100644 (file)
@@ -108,4 +108,16 @@ class format_social extends format_base {
         }
         return $courseformatoptions;
     }
+
+    /**
+     * Returns whether this course format allows the activity to
+     * have "triple visibility state" - visible always, hidden on course page but available, hidden.
+     *
+     * @param stdClass|cm_info $cm course module (may be null if we are displaying a form for adding a module)
+     * @param stdClass|section_info $section section where this module is located or will be added to
+     * @return bool
+     */
+    public function allow_stealth_module_visibility($cm, $section) {
+        return true;
+    }
 }
index f5e0488..1bc58ab 100644 (file)
@@ -410,6 +410,36 @@ class format_topics extends format_base {
     public function supports_news() {
         return true;
     }
+
+    /**
+     * Returns whether this course format allows the activity to
+     * have "triple visibility state" - visible always, hidden on course page but available, hidden.
+     *
+     * @param stdClass|cm_info $cm course module (may be null if we are displaying a form for adding a module)
+     * @param stdClass|section_info $section section where this module is located or will be added to
+     * @return bool
+     */
+    public function allow_stealth_module_visibility($cm, $section) {
+        // Allow the third visibility state inside visible sections or in section 0, not allow in orphaned sections.
+        return !$section->section || ($section->visible && $section->section <= $this->get_course()->numsections);
+    }
+
+    public function section_action($section, $action, $sr) {
+        global $PAGE;
+
+        if ($section->section && ($action === 'setmarker' || $action === 'removemarker')) {
+            // Format 'topics' allows to set and remove markers in addition to common section actions.
+            require_capability('moodle/course:setcurrentsection', context_course::instance($this->courseid));
+            course_set_marker($this->courseid, ($action === 'setmarker') ? $section->section : 0);
+            return null;
+        }
+
+        // For show/hide actions call the parent method and return the new content for .section_availability element.
+        $rv = parent::section_action($section, $action, $sr);
+        $renderer = $PAGE->get_renderer('format_topics');
+        $rv['section_availability'] = $renderer->section_availability($this->get_section($section));
+        return $rv;
+    }
 }
 
 /**
index 9ed95bd..963e066 100644 (file)
@@ -129,7 +129,8 @@ class format_topics_renderer extends format_section_renderer_base {
                 $controls['highlight'] = array('url' => $url, "icon" => 'i/marked',
                                                'name' => $highlightoff,
                                                'pixattr' => array('class' => '', 'alt' => $markedthistopic),
-                                               'attr' => array('class' => 'editing_highlight', 'title' => $markedthistopic));
+                                               'attr' => array('class' => 'editing_highlight', 'title' => $markedthistopic,
+                                                   'data-action' => 'removemarker'));
             } else {
                 $url->param('marker', $section->section);
                 $markthistopic = get_string('markthistopic');
@@ -137,7 +138,8 @@ class format_topics_renderer extends format_section_renderer_base {
                 $controls['highlight'] = array('url' => $url, "icon" => 'i/marker',
                                                'name' => $highlight,
                                                'pixattr' => array('class' => '', 'alt' => $markthistopic),
-                                               'attr' => array('class' => 'editing_highlight', 'title' => $markthistopic));
+                                               'attr' => array('class' => 'editing_highlight', 'title' => $markthistopic,
+                                                   'data-action' => 'setmarker'));
             }
         }
 
index ccc38b6..f5cf7ca 100644 (file)
@@ -2,6 +2,15 @@ This files describes API changes for course formats
 
 Overview of this plugin type at http://docs.moodle.org/dev/Course_formats
 
+=== 3.3 ===
+* Javascript code for editing activities and sections was moved to an AMD module, course/rest.php is no longer
+  responsible for editing actions, instead it is done in web services. Carefully test all editing actions during upgrade.
+* The new method format_base::allow_stealth_module_visibility() can indicate whether course format supports "stealth"
+  activities mode when they are available but not visible on course page. Course format that supports stealth mode
+  must check $cm->is_visible_on_course_page() when displaying activities list on the course page instead of $cm->uservisible.
+  For all other plugins except course formats the same property $cm->uservisible indicates if the activity contents
+  is actually available to student.
+
 === 3.2 ===
 * Callback delete_course is deprecated and should be replaced with observer for event \core\event\course_content_deleted
 * Course formats can overwrite get_default_course_enddate function to set the default course end date for new courses.
index af0fba4..408b590 100644 (file)
@@ -497,6 +497,29 @@ class format_weeks extends format_base {
     public function supports_news() {
         return true;
     }
+
+    /**
+     * Returns whether this course format allows the activity to
+     * have "triple visibility state" - visible always, hidden on course page but available, hidden.
+     *
+     * @param stdClass|cm_info $cm course module (may be null if we are displaying a form for adding a module)
+     * @param stdClass|section_info $section section where this module is located or will be added to
+     * @return bool
+     */
+    public function allow_stealth_module_visibility($cm, $section) {
+        // Allow the third visibility state inside visible sections or in section 0, not allow in orphaned sections.
+        return !$section->section || ($section->visible && $section->section <= $this->get_course()->numsections);
+    }
+
+    public function section_action($section, $action, $sr) {
+        global $PAGE;
+
+        // Call the parent method and return the new content for .section_availability element.
+        $rv = parent::section_action($section, $action, $sr);
+        $renderer = $PAGE->get_renderer('format_weeks');
+        $rv['section_availability'] = $renderer->section_availability($this->get_section($section));
+        return $rv;
+    }
 }
 
 /**
index 2262711..4fdcc4d 100644 (file)
@@ -388,13 +388,16 @@ function get_array_of_activities($courseid) {
     if (empty($rawmods)) {
         return $mod; // always return array
     }
+    $courseformat = course_get_format($course);
 
-    if ($sections = $DB->get_records('course_sections', array('course' => $courseid), 'section ASC', 'id,section,sequence')) {
+    if ($sections = $DB->get_records('course_sections', array('course' => $courseid),
+            'section ASC', 'id,section,sequence,visible')) {
         // First check and correct obvious mismatches between course_sections.sequence and course_modules.section.
         if ($errormessages = course_integrity_check($courseid, $rawmods, $sections)) {
             debugging(join('<br>', $errormessages));
             $rawmods = get_course_mods($courseid);
-            $sections = $DB->get_records('course_sections', array('course' => $courseid), 'section ASC', 'id,section,sequence');
+            $sections = $DB->get_records('course_sections', array('course' => $courseid),
+                'section ASC', 'id,section,sequence,visible');
         }
         // Build array of activities.
        foreach ($sections as $section) {
@@ -404,6 +407,13 @@ function get_array_of_activities($courseid) {
                    if (empty($rawmods[$seq])) {
                        continue;
                    }
+                   // Adjust visibleoncoursepage, value in DB may not respect format availability.
+                   $rawmods[$seq]->visibleoncoursepage = (!$rawmods[$seq]->visible
+                           || $rawmods[$seq]->visibleoncoursepage
+                           || empty($CFG->allowstealth)
+                           || !$courseformat->allow_stealth_module_visibility($rawmods[$seq], $section)) ? 1 : 0;
+
+                   // Create an object that will be cached.
                    $mod[$seq] = new stdClass();
                    $mod[$seq]->id               = $rawmods[$seq]->instance;
                    $mod[$seq]->cm               = $rawmods[$seq]->id;
@@ -418,6 +428,7 @@ function get_array_of_activities($courseid) {
                    $mod[$seq]->score            = $rawmods[$seq]->score;
                    $mod[$seq]->idnumber         = $rawmods[$seq]->idnumber;
                    $mod[$seq]->visible          = $rawmods[$seq]->visible;
+                   $mod[$seq]->visibleoncoursepage = $rawmods[$seq]->visibleoncoursepage;
                    $mod[$seq]->visibleold       = $rawmods[$seq]->visibleold;
                    $mod[$seq]->groupmode        = $rawmods[$seq]->groupmode;
                    $mod[$seq]->groupingid       = $rawmods[$seq]->groupingid;
@@ -557,9 +568,15 @@ function get_module_types_names($plural = false) {
  * @return void
  */
 function course_set_marker($courseid, $marker) {
-    global $DB;
+    global $DB, $COURSE;
     $DB->set_field("course", "marker", $marker, array('id' => $courseid));
-    format_base::reset_course_cache($courseid);
+    if ($COURSE && $COURSE->id == $courseid) {
+        $COURSE->marker = $marker;
+    }
+    if (class_exists('format_base')) {
+        format_base::reset_course_cache($courseid);
+    }
+    course_modinfo::clear_instance_cache($courseid);
 }
 
 /**
@@ -950,33 +967,29 @@ function set_coursemodule_idnumber($id, $idnumber) {
  *
  * @param int $id of the module
  * @param int $visible state of the module
+ * @param int $visibleoncoursepage state of the module on the course page
  * @return bool false when the module was not found, true otherwise
  */
-function set_coursemodule_visible($id, $visible) {
+function set_coursemodule_visible($id, $visible, $visibleoncoursepage = 1) {
     global $DB, $CFG;
     require_once($CFG->libdir.'/gradelib.php');
     require_once($CFG->dirroot.'/calendar/lib.php');
 
-    // Trigger developer's attention when using the previously removed argument.
-    if (func_num_args() > 2) {
-        debugging('Wrong number of arguments passed to set_coursemodule_visible(), $prevstateoverrides
-            has been removed.', DEBUG_DEVELOPER);
-    }
-
     if (!$cm = $DB->get_record('course_modules', array('id'=>$id))) {
         return false;
     }
 
     // Create events and propagate visibility to associated grade items if the value has changed.
     // Only do this if it's changed to avoid accidently overwriting manual showing/hiding of student grades.
-    if ($cm->visible == $visible) {
+    if ($cm->visible == $visible && $cm->visibleoncoursepage == $visibleoncoursepage) {
         return true;
     }
 
     if (!$modulename = $DB->get_field('modules', 'name', array('id'=>$cm->module))) {
         return false;
     }
-    if ($events = $DB->get_records('event', array('instance'=>$cm->instance, 'modulename'=>$modulename))) {
+    if (($cm->visible != $visible) &&
+            ($events = $DB->get_records('event', array('instance' => $cm->instance, 'modulename' => $modulename)))) {
         foreach($events as $event) {
             if ($visible) {
                 $event = new calendar_event($event);
@@ -993,17 +1006,19 @@ function set_coursemodule_visible($id, $visible) {
     $cminfo = new stdClass();
     $cminfo->id = $id;
     $cminfo->visible = $visible;
+    $cminfo->visibleoncoursepage = $visibleoncoursepage;
     $cminfo->visibleold = $visible;
     $DB->update_record('course_modules', $cminfo);
 
     // Hide the associated grade items so the teacher doesn't also have to go to the gradebook and hide them there.
     // Note that this must be done after updating the row in course_modules, in case
     // the modules grade_item_update function needs to access $cm->visible.
-    if (plugin_supports('mod', $modulename, FEATURE_CONTROLS_GRADE_VISIBILITY) &&
+    if ($cm->visible != $visible &&
+            plugin_supports('mod', $modulename, FEATURE_CONTROLS_GRADE_VISIBILITY) &&
             component_callback_exists('mod_' . $modulename, 'grade_item_update')) {
         $instance = $DB->get_record($modulename, array('id' => $cm->instance), '*', MUST_EXIST);
         component_callback('mod_' . $modulename, 'grade_item_update', array($instance));
-    } else {
+    } else if ($cm->visible != $visible) {
         $grade_items = grade_item::fetch_all(array('itemtype'=>'mod', 'itemmodule'=>$modulename, 'iteminstance'=>$cm->instance, 'courseid'=>$cm->course));
         if ($grade_items) {
             foreach ($grade_items as $grade_item) {
@@ -1593,10 +1608,10 @@ function course_update_section($course, $section, $data) {
             if ($cm = get_coursemodule_from_id(null, $moduleid, $courseid)) {
                 if ($data['visible']) {
                     // As we unhide the section, we use the previously saved visibility stored in visibleold.
-                    set_coursemodule_visible($moduleid, $cm->visibleold);
+                    set_coursemodule_visible($moduleid, $cm->visibleold, $cm->visibleoncoursepage);
                 } else {
                     // We hide the section, so we hide the module but we store the original state in visibleold.
-                    set_coursemodule_visible($moduleid, 0);
+                    set_coursemodule_visible($moduleid, 0, $cm->visibleoncoursepage);
                     $DB->set_field('course_modules', 'visibleold', $cm->visible, array('id' => $moduleid));
                 }
                 \core\event\course_module_updated::create_from_cm($cm)->trigger();
@@ -1765,12 +1780,13 @@ function moveto_module($mod, $section, $beforemod=NULL) {
  * @return array array of action_link or pix_icon objects
  */
 function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
-    global $COURSE, $SITE;
+    global $COURSE, $SITE, $CFG;
 
     static $str;
 
     $coursecontext = context_course::instance($mod->course);
     $modcontext = context_module::instance($mod->id);
+    $courseformat = course_get_format($mod->get_course());
 
     $editcaps = array('moodle/course:manageactivities', 'moodle/course:activityvisibility', 'moodle/role:assign');
     $dupecaps = array('moodle/backup:backuptargetimport', 'moodle/restore:restoretargetimport');
@@ -1784,7 +1800,7 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
 
     if (!isset($str)) {
         $str = get_strings(array('delete', 'move', 'moveright', 'moveleft',
-            'editsettings', 'duplicate', 'hide', 'show'), 'moodle');
+            'editsettings', 'duplicate', 'hide', 'makeavailable', 'makeunavailable', 'show'), 'moodle');
         $str->assign         = get_string('assignroles', 'role');
         $str->groupsnone     = get_string('clicktochangeinbrackets', 'moodle', get_string("groupsnone"));
         $str->groupsseparate = get_string('clicktochangeinbrackets', 'moodle', get_string("groupsseparate"));
@@ -1830,7 +1846,8 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
             new moodle_url($baseurl, array('id' => $mod->id, 'indent' => '1')),
             new pix_icon($rightarrow, $str->moveright, 'moodle', array('class' => 'iconsmall', 'title' => '')),
             $str->moveright,
-            array('class' => 'editing_moveright ' . $enabledclass, 'data-action' => 'moveright', 'data-keepopen' => true)
+            array('class' => 'editing_moveright ' . $enabledclass, 'data-action' => 'moveright',
+                'data-keepopen' => true, 'data-sectionreturn' => $sr)
         );
 
         if ($indent <= $indentlimits->min) {
@@ -1842,21 +1859,33 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
             new moodle_url($baseurl, array('id' => $mod->id, 'indent' => '-1')),
             new pix_icon($leftarrow, $str->moveleft, 'moodle', array('class' => 'iconsmall', 'title' => '')),
             $str->moveleft,
-            array('class' => 'editing_moveleft ' . $enabledclass, 'data-action' => 'moveleft', 'data-keepopen' => true)
+            array('class' => 'editing_moveleft ' . $enabledclass, 'data-action' => 'moveleft',
+                'data-keepopen' => true, 'data-sectionreturn' => $sr)
         );
 
     }
 
-    // Hide/Show.
+    // Hide/Show/Available/Unavailable.
     if (has_capability('moodle/course:activityvisibility', $modcontext)) {
-        if ($mod->visible) {
+        $allowstealth = !empty($CFG->allowstealth) && $courseformat->allow_stealth_module_visibility($mod, $mod->get_section_info());
+
+        $sectionvisible = $mod->get_section_info()->visible;
+        // The module on the course page may be in one of the following states:
+        // - Available and displayed on the course page ($displayedoncoursepage);
+        // - Not available and not displayed on the course page ($unavailable);
+        // - Available but not displayed on the course page ($stealth) - this can also be a visible activity in a hidden section.
+        $displayedoncoursepage = $mod->visible && $mod->visibleoncoursepage && $sectionvisible;
+        $unavailable = !$mod->visible;
+        $stealth = $mod->visible && (!$mod->visibleoncoursepage || !$sectionvisible);
+        if ($displayedoncoursepage) {
             $actions['hide'] = new action_menu_link_secondary(
                 new moodle_url($baseurl, array('hide' => $mod->id)),
                 new pix_icon('t/hide', $str->hide, 'moodle', array('class' => 'iconsmall', 'title' => '')),
                 $str->hide,
                 array('class' => 'editing_hide', 'data-action' => 'hide')
             );
-        } else {
+        } else if (!$displayedoncoursepage && $sectionvisible) {
+            // Offer to "show" only if the section is visible.
             $actions['show'] = new action_menu_link_secondary(
                 new moodle_url($baseurl, array('show' => $mod->id)),
                 new pix_icon('t/show', $str->show, 'moodle', array('class' => 'iconsmall', 'title' => '')),
@@ -1864,16 +1893,38 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
                 array('class' => 'editing_show', 'data-action' => 'show')
             );
         }
+
+        if ($stealth) {
+            // When making the "stealth" module unavailable we perform the same action as hiding the visible module.
+            $actions['hide'] = new action_menu_link_secondary(
+                new moodle_url($baseurl, array('hide' => $mod->id)),
+                new pix_icon('t/unblock', $str->makeunavailable, 'moodle', array('class' => 'iconsmall', 'title' => '')),
+                $str->makeunavailable,
+                array('class' => 'editing_makeunavailable', 'data-action' => 'hide', 'data-sectionreturn' => $sr)
+            );
+        } else if ($unavailable && (!$sectionvisible || $allowstealth) && $mod->has_view()) {
+            // Allow to make visually hidden module available in gradebook and other reports by making it a "stealth" module.
+            // When the section is hidden it is an equivalent of "showing" the module.
+            // Activities without the link (i.e. labels) can not be made available but hidden on course page.
+            $action = $sectionvisible ? 'stealth' : 'show';
+            $actions[$action] = new action_menu_link_secondary(
+                new moodle_url($baseurl, array($action => $mod->id)),
+                new pix_icon('t/block', $str->makeavailable, 'moodle', array('class' => 'iconsmall', 'title' => '')),
+                $str->makeavailable,
+                array('class' => 'editing_makeavailable', 'data-action' => $action, 'data-sectionreturn' => $sr)
+            );
+        }
     }
 
     // Duplicate (require both target import caps to be able to duplicate and backup2 support, see modduplicate.php)
     if (has_all_capabilities($dupecaps, $coursecontext) &&
-            plugin_supports('mod', $mod->modname, FEATURE_BACKUP_MOODLE2)) {
+            plugin_supports('mod', $mod->modname, FEATURE_BACKUP_MOODLE2) &&
+            course_allowed_module($mod->get_course(), $mod->modname)) {
         $actions['duplicate'] = new action_menu_link_secondary(
             new moodle_url($baseurl, array('duplicate' => $mod->id)),
             new pix_icon('t/copy', $str->duplicate, 'moodle', array('class' => 'iconsmall', 'title' => '')),
             $str->duplicate,
-            array('class' => 'editing_duplicate', 'data-action' => 'duplicate', 'data-sr' => $sr)
+            array('class' => 'editing_duplicate', 'data-action' => 'duplicate', 'data-sectionreturn' => $sr)
         );
     }
 
@@ -1884,16 +1935,19 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
                 $nextgroupmode = VISIBLEGROUPS;
                 $grouptitle = $str->groupsseparate;
                 $actionname = 'groupsseparate';
+                $nextactionname = 'groupsvisible';
                 $groupimage = 'i/groups';
             } else if ($mod->effectivegroupmode == VISIBLEGROUPS) {
                 $nextgroupmode = NOGROUPS;
                 $grouptitle = $str->groupsvisible;
                 $actionname = 'groupsvisible';
+                $nextactionname = 'groupsnone';
                 $groupimage = 'i/groupv';
             } else {
                 $nextgroupmode = SEPARATEGROUPS;
                 $grouptitle = $str->groupsnone;
                 $actionname = 'groupsnone';
+                $nextactionname = 'groupsseparate';
                 $groupimage = 'i/groupn';
             }
 
@@ -1901,7 +1955,8 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
                 new moodle_url($baseurl, array('id' => $mod->id, 'groupmode' => $nextgroupmode)),
                 new pix_icon($groupimage, null, 'moodle', array('class' => 'iconsmall')),
                 $grouptitle,
-                array('class' => 'editing_'. $actionname, 'data-action' => $actionname, 'data-nextgroupmode' => $nextgroupmode, 'aria-live' => 'assertive')
+                array('class' => 'editing_'. $actionname, 'data-action' => $nextactionname,
+                    'aria-live' => 'assertive', 'data-sectionreturn' => $sr)
             );
         } else {
             $actions['nogroupsupport'] = new action_menu_filler();
@@ -1914,7 +1969,7 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
             new moodle_url('/admin/roles/assign.php', array('contextid' => $modcontext->id)),
             new pix_icon('t/assignroles', $str->assign, 'moodle', array('class' => 'iconsmall', 'title' => '')),
             $str->assign,
-            array('class' => 'editing_assign', 'data-action' => 'assignroles')
+            array('class' => 'editing_assign', 'data-action' => 'assignroles', 'data-sectionreturn' => $sr)
         );
     }
 
@@ -1924,7 +1979,7 @@ function course_get_cm_edit_actions(cm_info $mod, $indent = -1, $sr = null) {
             new moodle_url($baseurl, array('delete' => $mod->id)),
             new pix_icon('t/delete', $str->delete, 'moodle', array('class' => 'iconsmall', 'title' => '')),
             $str->delete,
-            array('class' => 'editing_delete', 'data-action' => 'delete')
+            array('class' => 'editing_delete', 'data-action' => 'delete', 'data-sectionreturn' => $sr)
         );
     }
 
@@ -1970,7 +2025,7 @@ function course_get_cm_move(cm_info $mod, $sr = null) {
         return html_writer::link(
             new moodle_url($baseurl, array('copy' => $mod->id)),
             $OUTPUT->pix_icon($pixicon, $str->move, 'moodle', array('class' => 'iconsmall', 'title' => '')),
-            array('class' => 'editing_move', 'data-action' => 'move')
+            array('class' => 'editing_move', 'data-action' => 'move', 'data-sectionreturn' => $sr)
         );
     }
     return '';
@@ -3007,25 +3062,6 @@ function include_course_ajax($course, $usedmodules = array(), $enabledmodules =
         $config->pageparams = array();
     }
 
-    // Include toolboxes
-    $PAGE->requires->yui_module('moodle-course-toolboxes',
-            'M.course.init_resource_toolbox',
-            array(array(
-                'courseid' => $course->id,
-                'ajaxurl' => $config->resourceurl,
-                'config' => $config,
-            ))
-    );
-    $PAGE->requires->yui_module('moodle-course-toolboxes',
-            'M.course.init_section_toolbox',
-            array(array(
-                'courseid' => $course->id,
-                'format' => $course->format,
-                'ajaxurl' => $config->sectionurl,
-                'config' => $config,
-            ))
-    );
-
     // Include course dragdrop
     if (course_format_uses_sections($course->format)) {
         $PAGE->requires->yui_module('moodle-course-dragdrop', 'M.course.init_section_dragdrop',
@@ -3088,6 +3124,8 @@ function include_course_ajax($course, $usedmodules = array(), $enabledmodules =
     require_once($CFG->dirroot.'/course/dnduploadlib.php');
     dndupload_add_to_course($course, $enabledmodules);
 
+    $PAGE->requires->js_call_amd('core_course/actions', 'initCoursePage', array($course->format));
+
     return true;
 }
 
index 9c23493..54f6f9c 100644 (file)
@@ -33,6 +33,7 @@ $indent        = optional_param('indent', 0, PARAM_INT);
 $update        = optional_param('update', 0, PARAM_INT);
 $duplicate     = optional_param('duplicate', 0, PARAM_INT);
 $hide          = optional_param('hide', 0, PARAM_INT);
+$stealth       = optional_param('stealth', 0, PARAM_INT);
 $show          = optional_param('show', 0, PARAM_INT);
 $copy          = optional_param('copy', 0, PARAM_INT);
 $moveto        = optional_param('moveto', 0, PARAM_INT);
@@ -202,28 +203,30 @@ if ((!empty($movetosection) or !empty($moveto)) and confirm_sesskey()) {
     $modcontext = context_module::instance($cm->id);
     require_capability('moodle/course:activityvisibility', $modcontext);
 
-    set_coursemodule_visible($cm->id, 0);
-    \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
+    if (set_coursemodule_visible($cm->id, 0)) {
+        \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
+    }
     redirect(course_get_url($course, $cm->sectionnum, array('sr' => $sectionreturn)));
 
-} else if (!empty($show) and confirm_sesskey()) {
-    $cm     = get_coursemodule_from_id('', $show, 0, true, MUST_EXIST);
-    $course = $DB->get_record('course', array('id' => $cm->course), '*', MUST_EXIST);
-
+} else if (!empty($stealth) and confirm_sesskey()) {
+    list($course, $cm) = get_course_and_cm_from_cmid($stealth);
     require_login($course, false, $cm);
-    $coursecontext = context_course::instance($course->id);
-    $modcontext = context_module::instance($cm->id);
-    require_capability('moodle/course:activityvisibility', $modcontext);
+    require_capability('moodle/course:activityvisibility', $cm->context);
 
-    $section = $DB->get_record('course_sections', array('id'=>$cm->section), '*', MUST_EXIST);
+    if (set_coursemodule_visible($cm->id, 1, 0)) {
+        \core\event\course_module_updated::create_from_cm($cm)->trigger();
+    }
+    redirect(course_get_url($course, $cm->sectionnum, array('sr' => $sectionreturn)));
 
-    $module = $DB->get_record('modules', array('id'=>$cm->module), '*', MUST_EXIST);
+} else if (!empty($show) and confirm_sesskey()) {
+    list($course, $cm) = get_course_and_cm_from_cmid($show);
+    require_login($course, false, $cm);
+    require_capability('moodle/course:activityvisibility', $cm->context);
+    $section = $cm->get_section_info();
 
-    if ($module->visible and ($section->visible or (SITEID == $cm->course))) {
-        set_coursemodule_visible($cm->id, 1);
-        \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
+    if (set_coursemodule_visible($cm->id, 1)) {
+        \core\event\course_module_updated::create_from_cm($cm)->trigger();
     }
-
     redirect(course_get_url($course, $section->section, array('sr' => $sectionreturn)));
 
 } else if ($groupmode > -1 and confirm_sesskey()) {
index d45966d..68ed661 100644 (file)
@@ -60,6 +60,7 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
     $newcm->module           = $moduleinfo->module;
     $newcm->instance         = 0; // Not known yet, will be updated later (this is similar to restore code).
     $newcm->visible          = $moduleinfo->visible;
+    $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
     $newcm->visibleold       = $moduleinfo->visible;
     if (isset($moduleinfo->cmidnumber)) {
         $newcm->idnumber         = $moduleinfo->cmidnumber;
@@ -597,7 +598,7 @@ function update_moduleinfo($cm, $moduleinfo, $course, $mform = null) {
 
     // Make sure visibility is set correctly (in particular in calendar).
     if (has_capability('moodle/course:activityvisibility', $modcontext)) {
-        set_coursemodule_visible($moduleinfo->coursemodule, $moduleinfo->visible);
+        set_coursemodule_visible($moduleinfo->coursemodule, $moduleinfo->visible, $moduleinfo->visibleoncoursepage);
     }
 
     if (isset($moduleinfo->cmidnumber)) { // Label.
@@ -655,6 +656,7 @@ function get_moduleinfo_data($cm, $course) {
     $data->coursemodule       = $cm->id;
     $data->section            = $cw->section;  // The section number itself - relative!!! (section column in course_sections)
     $data->visible            = $cm->visible; //??  $cw->visible ? $cm->visible : 0; // section hiding overrides
+    $data->visibleoncoursepage = $cm->visibleoncoursepage;
     $data->cmidnumber         = $cm->idnumber;          // The cm IDnumber
     $data->groupmode          = groups_get_activity_groupmode($cm); // locked later if forced
     $data->groupingid         = $cm->groupingid;
index f5c76e0..f98c1fd 100644 (file)
@@ -21,7 +21,7 @@ abstract class moodleform_mod extends moodleform {
      * Section of course that module instance will be put in or is in.
      * This is always the section number itself (column 'section' from 'course_sections' table).
      *
-     * @var mixed
+     * @var int
      */
     protected $_section;
     /**
@@ -555,7 +555,10 @@ abstract class moodleform_mod extends moodleform {
 
         $mform->addElement('header', 'modstandardelshdr', get_string('modstandardels', 'form'));
 
-        $mform->addElement('modvisible', 'visible', get_string('visible'));
+        $section = get_fast_modinfo($COURSE)->get_section_info($this->_section);
+        $allowstealth = !empty($CFG->allowstealth) && $this->courseformat->allow_stealth_module_visibility($this->_cm, $section);
+        $mform->addElement('modvisible', 'visible', get_string('visible'), null,
+                array('allowstealth' => $allowstealth, 'sectionvisible' => $section->visible, 'cm' => $this->_cm));
         if (!empty($this->_cm)) {
             $context = context_module::instance($this->_cm->id);
             if (!has_capability('moodle/course:activityvisibility', $context)) {
index 90ad00f..6a6c100 100644 (file)
@@ -450,7 +450,7 @@ class core_course_renderer extends plugin_renderer_base {
     public function course_section_cm_completion($course, &$completioninfo, cm_info $mod, $displayoptions = array()) {
         global $CFG;
         $output = '';
-        if (!empty($displayoptions['hidecompletion']) || !isloggedin() || isguestuser() || !$mod->uservisible) {
+        if (!$mod->is_visible_on_course_page()) {
             return $output;
         }
         if ($completioninfo === null) {
@@ -582,15 +582,58 @@ class core_course_renderer extends plugin_renderer_base {
      * @return string
      */
     public function course_section_cm_name(cm_info $mod, $displayoptions = array()) {
-        if ((!$mod->uservisible && empty($mod->availableinfo)) || !$mod->url) {
+        if (!$mod->is_visible_on_course_page() || !$mod->url) {
             // Nothing to be displayed to the user.
             return '';
         }
 
+        list($linkclasses, $textclasses) = $this->course_section_cm_classes($mod);
+        $groupinglabel = $mod->get_grouping_label($textclasses);
+
         // Render element that allows to edit activity name inline. It calls {@link course_section_cm_name_title()}
         // to get the display title of the activity.
         $tmpl = new \core_course\output\course_module_name($mod, $this->page->user_is_editing(), $displayoptions);
-        return $this->output->render_from_template('core/inplace_editable', $tmpl->export_for_template($this->output));
+        return $this->output->render_from_template('core/inplace_editable', $tmpl->export_for_template($this->output)) .
+            $groupinglabel;
+    }
+
+    /**
+     * Returns the CSS classes for the activity name/content
+     *
+     * For items which are hidden, unavailable or stealth but should be displayed
+     * to current user ($mod->is_visible_on_course_page()), we show those as dimmed.
+     * Students will also see as dimmed activities names that are not yet available
+     * but should still be displayed (without link) with availability info.
+     *
+     * @param cm_info $mod
+     * @return array array of two elements ($linkclasses, $textclasses)
+     */
+    protected function course_section_cm_classes(cm_info $mod) {
+        $linkclasses = '';
+        $textclasses = '';
+        if ($mod->uservisible) {
+            $conditionalhidden = $this->is_cm_conditionally_hidden($mod);
+            $accessiblebutdim = (!$mod->visible || $conditionalhidden) &&
+                has_capability('moodle/course:viewhiddenactivities', $mod->context);
+            if ($accessiblebutdim) {
+                $linkclasses .= ' dimmed';
+                $textclasses .= ' dimmed_text';
+                if ($conditionalhidden) {
+                    $linkclasses .= ' conditionalhidden';
+                    $textclasses .= ' conditionalhidden';
+                }
+            }
+            if ($mod->is_stealth()) {
+                // Stealth activity is the one that is not visible on course page.
+                // It still may be displayed to the users who can manage it.
+                $linkclasses .= ' stealth';
+                $textclasses .= ' stealth';
+            }
+        } else {
+            $linkclasses .= ' dimmed';
+            $textclasses .= ' dimmed_text';
+        }
+        return array($linkclasses, $textclasses);
     }
 
     /**
@@ -608,12 +651,9 @@ class core_course_renderer extends plugin_renderer_base {
      */
     public function course_section_cm_name_title(cm_info $mod, $displayoptions = array()) {
         $output = '';
-        if (!$mod->uservisible && empty($mod->availableinfo)) {
-            // Nothing to be displayed to the user.
-            return $output;
-        }
         $url = $mod->url;
-        if (!$url) {
+        if (!$mod->is_visible_on_course_page() || !$url) {
+            // Nothing to be displayed to the user.
             return $output;
         }
 
@@ -632,51 +672,22 @@ class core_course_renderer extends plugin_renderer_base {
             $altname = get_accesshide(' '.$altname);
         }
 
-        // For items which are hidden but available to current user
-        // ($mod->uservisible), we show those as dimmed only if the user has
-        // viewhiddenactivities, so that teachers see 'items which might not
-        // be available to some students' dimmed but students do not see 'item
-        // which is actually available to current student' dimmed.
-        $linkclasses = '';
-        $accesstext = '';
-        $textclasses = '';
-        if ($mod->uservisible) {
-            $conditionalhidden = $this->is_cm_conditionally_hidden($mod);
-            $accessiblebutdim = (!$mod->visible || $conditionalhidden) &&
-                has_capability('moodle/course:viewhiddenactivities', $mod->context);
-            if ($accessiblebutdim) {
-                $linkclasses .= ' dimmed';
-                $textclasses .= ' dimmed_text';
-                if ($conditionalhidden) {
-                    $linkclasses .= ' conditionalhidden';
-                    $textclasses .= ' conditionalhidden';
-                }
-                // Show accessibility note only if user can access the module himself.
-                $accesstext = get_accesshide(get_string('hiddenfromstudents').':'. $mod->modfullname);
-            }
-        } else {
-            $linkclasses .= ' dimmed';
-            $textclasses .= ' dimmed_text';
-        }
+        list($linkclasses, $textclasses) = $this->course_section_cm_classes($mod);
 
         // Get on-click attribute value if specified and decode the onclick - it
         // has already been encoded for display (puke).
         $onclick = htmlspecialchars_decode($mod->onclick, ENT_QUOTES);
 
-        $groupinglabel = $mod->get_grouping_label($textclasses);
-
         // Display link itself.
         $activitylink = html_writer::empty_tag('img', array('src' => $mod->get_icon_url(),
-                'class' => 'iconlarge activityicon', 'alt' => ' ', 'role' => 'presentation')) . $accesstext .
+                'class' => 'iconlarge activityicon', 'alt' => ' ', 'role' => 'presentation')) .
                 html_writer::tag('span', $instancename . $altname, array('class' => 'instancename'));
         if ($mod->uservisible) {
-            $output .= html_writer::link($url, $activitylink, array('class' => $linkclasses, 'onclick' => $onclick)) .
-                    $groupinglabel;
+            $output .= html_writer::link($url, $activitylink, array('class' => $linkclasses, 'onclick' => $onclick));
         } else {
             // We may be displaying this just in order to show information
-            // about visibility, without the actual link ($mod->uservisible)
-            $output .= html_writer::tag('div', $activitylink, array('class' => $textclasses)) .
-                    $groupinglabel;
+            // about visibility, without the actual link ($mod->is_visible_on_course_page()).
+            $output .= html_writer::tag('div', $activitylink, array('class' => $textclasses));
         }
         return $output;
     }
@@ -690,29 +701,13 @@ class core_course_renderer extends plugin_renderer_base {
      */
     public function course_section_cm_text(cm_info $mod, $displayoptions = array()) {
         $output = '';
-        if (!$mod->uservisible && empty($mod->availableinfo)) {
+        if (!$mod->is_visible_on_course_page()) {
             // nothing to be displayed to the user
             return $output;
         }
         $content = $mod->get_formatted_content(array('overflowdiv' => true, 'noclean' => true));
-        $accesstext = '';
-        $textclasses = '';
-        if ($mod->uservisible) {
-            $conditionalhidden = $this->is_cm_conditionally_hidden($mod);
-            $accessiblebutdim = (!$mod->visible || $conditionalhidden) &&
-                has_capability('moodle/course:viewhiddenactivities', $mod->context);
-            if ($accessiblebutdim) {
-                $textclasses .= ' dimmed_text';
-                if ($conditionalhidden) {
-                    $textclasses .= ' conditionalhidden';
-                }
-                // Show accessibility note only if user can access the module himself.
-                $accesstext = get_accesshide(get_string('hiddenfromstudents').':'. $mod->modfullname);
-            }
-        } else {
-            $textclasses .= ' dimmed_text';
-        }
-        if ($mod->url) {
+        list($linkclasses, $textclasses) = $this->course_section_cm_classes($mod);
+        if ($mod->url && $mod->uservisible) {
             if ($content) {
                 // If specified, display extra content after link.
                 $output = html_writer::tag('div', $content, array('class' =>
@@ -722,12 +717,24 @@ class core_course_renderer extends plugin_renderer_base {
             $groupinglabel = $mod->get_grouping_label($textclasses);
 
             // No link, so display only content.
-            $output = html_writer::tag('div', $accesstext . $content . $groupinglabel,
+            $output = html_writer::tag('div', $content . $groupinglabel,
                     array('class' => 'contentwithoutlink ' . $textclasses));
         }
         return $output;
     }
 
+    /**
+     * Displays availability info for a course section or course module
+     *
+     * @param string $text
+     * @param string $additionalclasses
+     * @return string
+     */
+    public function availability_info($text, $additionalclasses = '') {
+        $data = ['text' => $text, 'classes' => $additionalclasses];
+        return $this->render_from_template('core/availability_info', $data);
+    }
+
     /**
      * Renders HTML to show course module availability information (for someone who isn't allowed
      * to see the activity itself, or for staff)
@@ -738,13 +745,17 @@ class core_course_renderer extends plugin_renderer_base {
      */
     public function course_section_cm_availability(cm_info $mod, $displayoptions = array()) {
         global $CFG;
+        $output = '';
+        if (!$mod->is_visible_on_course_page()) {
+            return $output;
+        }
         if (!$mod->uservisible) {
             // this is a student who is not allowed to see the module but might be allowed
             // to see availability info (i.e. "Available from ...")
             if (!empty($mod->availableinfo)) {
                 $formattedinfo = \core_availability\info::format_info(
                         $mod->availableinfo, $mod->get_course());
-                $output = html_writer::tag('div', $formattedinfo, array('class' => 'availabilityinfo'));
+                $output = $this->availability_info($formattedinfo);
             }
             return $output;
         }
@@ -752,7 +763,19 @@ class core_course_renderer extends plugin_renderer_base {
         // information that module is not available to all/some students
         $modcontext = context_module::instance($mod->id);
         $canviewhidden = has_capability('moodle/course:viewhiddenactivities', $modcontext);
+        if ($canviewhidden && !$mod->visible) {
+            // This module is hidden but current user has capability to see it.
+            // Do not display the availability info if the whole section is hidden.
+            if ($mod->get_section_info()->visible) {
+                $output .= $this->availability_info(get_string('hiddenfromstudents'), 'ishidden');
+            }
+        } else if ($mod->is_stealth()) {
+            // This module is available but is normally not displayed on the course page
+            // (this user can see it because they can manage it).
+            $output .= $this->availability_info(get_string('hiddenoncoursepage'), 'isstealth');
+        }
         if ($canviewhidden && !empty($CFG->enableavailability)) {
+            // Display information about conditional availability.
             // Don't add availability information if user is not editing and activity is hidden.
             if ($mod->visible || $this->page->user_is_editing()) {
                 $hidinfoclass = '';
@@ -764,11 +787,11 @@ class core_course_renderer extends plugin_renderer_base {
                 if ($fullinfo) {
                     $formattedinfo = \core_availability\info::format_info(
                             $fullinfo, $mod->get_course());
-                    return html_writer::div($formattedinfo, 'availabilityinfo ' . $hidinfoclass);
+                    $output .= $this->availability_info($formattedinfo, $hidinfoclass);
                 }
             }
         }
-        return '';
+        return $output;
     }
 
     /**
@@ -823,7 +846,7 @@ class core_course_renderer extends plugin_renderer_base {
         // 2) The 'availableinfo' is empty, i.e. the activity was
         //     hidden in a way that leaves no info, such as using the
         //     eye icon.
-        if (!$mod->uservisible && empty($mod->availableinfo)) {
+        if (!$mod->is_visible_on_course_page()) {
             return $output;
         }
 
@@ -890,15 +913,15 @@ class core_course_renderer extends plugin_renderer_base {
             $output .= html_writer::span($modicons, 'actions');
         }
 
+        // Show availability info (if module is not available).
+        $output .= $this->course_section_cm_availability($mod, $displayoptions);
+
         // If there is content AND a link, then display the content here
         // (AFTER any icons). Otherwise it was displayed before
         if (!empty($url)) {
             $output .= $contentpart;
         }
 
-        // show availability info (if module is not available)
-        $output .= $this->course_section_cm_availability($mod, $displayoptions);
-
         $output .= html_writer::end_tag('div'); // $indentclasses
 
         // End of indentation div.
index a63f6ce..e7d5b2e 100644 (file)
@@ -33,17 +33,10 @@ require_once($CFG->dirroot.'/course/lib.php');
 $courseid   = required_param('courseId', PARAM_INT);
 $class      = required_param('class', PARAM_ALPHA);
 $field      = optional_param('field', '', PARAM_ALPHA);
-$instanceid = optional_param('instanceId', 0, PARAM_INT);
 $sectionid  = optional_param('sectionId', 0, PARAM_INT);
 $beforeid   = optional_param('beforeId', 0, PARAM_INT);
 $value      = optional_param('value', 0, PARAM_INT);
-$column     = optional_param('column', 0, PARAM_ALPHA);
 $id         = optional_param('id', 0, PARAM_INT);
-$summary    = optional_param('summary', '', PARAM_RAW);
-$sequence   = optional_param('sequence', '', PARAM_SEQUENCE);
-$visible    = optional_param('visible', 0, PARAM_INT);
-$pageaction = optional_param('action', '', PARAM_ALPHA); // Used to simulate a DELETE command
-$title      = optional_param('title', '', PARAM_TEXT);
 
 $PAGE->set_url('/course/rest.php', array('courseId'=>$courseid,'class'=>$class));
 
@@ -63,113 +56,33 @@ require_sesskey();
 
 echo $OUTPUT->header(); // send headers
 
-// OK, now let's process the parameters and do stuff
-// MDL-10221 the DELETE method is not allowed on some web servers, so we simulate it with the action URL param
-$requestmethod = $_SERVER['REQUEST_METHOD'];
-if ($pageaction == 'DELETE') {
-    $requestmethod = 'DELETE';
-}
-
-switch($requestmethod) {
-    case 'POST':
-
-        switch ($class) {
-            case 'section':
-
-                if (!$DB->record_exists('course_sections', array('course'=>$course->id, 'section'=>$id))) {
-                    throw new moodle_exception('AJAX commands.php: Bad Section ID '.$id);
-                }
-
-                switch ($field) {
-                    case 'visible':
-                        require_capability('moodle/course:sectionvisibility', $coursecontext);
-                        $resourcestotoggle = set_section_visible($course->id, $id, $value);
-                        echo json_encode(array('resourcestotoggle' => $resourcestotoggle));
-                        break;
-
-                    case 'move':
-                        require_capability('moodle/course:movesections', $coursecontext);
-                        move_section_to($course, $id, $value);
-                        // See if format wants to do something about it
-                        $response = course_get_format($course)->ajax_section_move();
-                        if ($response !== null) {
-                            echo json_encode($response);
-                        }
-                        break;
-                }
-                break;
-
-            case 'resource':
-                switch ($field) {
-                    case 'visible':
-                        require_capability('moodle/course:activityvisibility', $modcontext);
-                        set_coursemodule_visible($cm->id, $value);
-                        \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
-                        break;
-
-                    case 'duplicate':
-                        require_capability('moodle/course:manageactivities', $coursecontext);
-                        require_capability('moodle/backup:backuptargetimport', $coursecontext);
-                        require_capability('moodle/restore:restoretargetimport', $coursecontext);
-                        if (!course_allowed_module($course, $cm->modname)) {
-                            throw new moodle_exception('No permission to create that activity');
-                        }
-                        $sr = optional_param('sr', null, PARAM_INT);
-                        $result = mod_duplicate_activity($course, $cm, $sr);
-                        echo json_encode($result);
-                        break;
-
-                    case 'groupmode':
-                        require_capability('moodle/course:manageactivities', $modcontext);
-                        set_coursemodule_groupmode($cm->id, $value);
-                        \core\event\course_module_updated::create_from_cm($cm, $modcontext)->trigger();
-                        break;
-
-                    case 'indent':
-                        require_capability('moodle/course:manageactivities', $modcontext);
-                        $cm->indent = $value;
-                        if ($cm->indent >= 0) {
-                            $DB->update_record('course_modules', $cm);
-                            rebuild_course_cache($cm->course);
-                        }
-                        break;
-
-                    case 'move':
-                        require_capability('moodle/course:manageactivities', $modcontext);
-                        if (!$section = $DB->get_record('course_sections', array('course'=>$course->id, 'section'=>$sectionid))) {
-                            throw new moodle_exception('AJAX commands.php: Bad section ID '.$sectionid);
-                        }
-
-                        if ($beforeid > 0){
-                            $beforemod = get_coursemodule_from_id('', $beforeid, $course->id);
-                            $beforemod = $DB->get_record('course_modules', array('id'=>$beforeid));
-                        } else {
-                            $beforemod = NULL;
-                        }
-
-                        $isvisible = moveto_module($cm, $section, $beforemod);
-                        echo json_encode(array('visible' => (bool) $isvisible));
-                        break;
-                }
-                break;
-
-            case 'course':
-                switch($field) {
-                    case 'marker':
-                        require_capability('moodle/course:setcurrentsection', $coursecontext);
-                        course_set_marker($course->id, $value);
-                        break;
-                }
-                break;
-        }
-        break;
-
-    case 'DELETE':
-        switch ($class) {
-            case 'resource':
-                require_capability('moodle/course:manageactivities', $modcontext);
-                course_delete_module($cm->id, true);
-                break;
-        }
-        break;
+if ($class === 'section' && $field === 'move') {
+    if (!$DB->record_exists('course_sections', array('course' => $course->id, 'section' => $id))) {
+        throw new moodle_exception('AJAX commands.php: Bad Section ID ' . $id);
+    }
+
+    require_capability('moodle/course:movesections', $coursecontext);
+    move_section_to($course, $id, $value);
+    // See if format wants to do something about it.
+    $response = course_get_format($course)->ajax_section_move();
+    if ($response !== null) {
+        echo json_encode($response);
+    }
+
+} else if ($class === 'resource' && $field === 'move') {
+
+    require_capability('moodle/course:manageactivities', $modcontext);
+    if (!$section = $DB->get_record('course_sections', array('course' => $course->id, 'section' => $sectionid))) {
+        throw new moodle_exception('AJAX commands.php: Bad section ID '.$sectionid);
+    }
+
+    if ($beforeid > 0) {
+        $beforemod = get_coursemodule_from_id('', $beforeid, $course->id);
+        $beforemod = $DB->get_record('course_modules', array('id' => $beforeid));
+    } else {
+        $beforemod = null;
+    }
+
+    $isvisible = moveto_module($cm, $section, $beforemod);
+    echo json_encode(array('visible' => (bool) $isvisible));
 }
index b9af5b1..a89880c 100644 (file)
@@ -25,35 +25,62 @@ Feature: Toggle activities visibility from the course page
       | Description | Test forum description |
       | Visible | Show |
     When I open "Test forum name" actions menu
+    Then "Test forum name" actions menu should not have "Show" item
+    And "Test forum name" actions menu should not have "Make available" item
+    And "Test forum name" actions menu should not have "Make unavailable" item
     And I click on "Hide" "link" in the "Test forum name" activity
-    Then "Test forum name" activity should be hidden
+    And "Test forum name" activity should be hidden
     And I open "Test forum name" actions menu
+    And "Test forum name" actions menu should not have "Hide" item
+    # Stealth behaviour is not available by default:
+    And "Test forum name" actions menu should not have "Make available" item
+    And "Test forum name" actions menu should not have "Make unavailable" item
     And I click on "Show" "link" in the "Test forum name" activity
     And "Test forum name" activity should be visible
     And I open "Test forum name" actions menu
+    And "Test forum name" actions menu should not have "Show" item
+    And "Test forum name" actions menu should not have "Make available" item
+    And "Test forum name" actions menu should not have "Make unavailable" item
     And I click on "Hide" "link" in the "Test forum name" activity
     And "Test forum name" activity should be hidden
     And I reload the page
     And "Test forum name" activity should be hidden
+    # Make sure that "Visible" dropdown in the edit menu has two options: Show/Hide.
+    And I open "Test forum name" actions menu
+    And I click on "Edit settings" "link" in the "Test forum name" activity
+    And I expand all fieldsets
+    And the "Visible" select box should not contain "Hidden from students"
+    And the "Visible" select box should not contain "Available but not displayed on course page"
+    And the "Visible" select box should contain "Show"
+    And the field "Visible" matches value "Hide"
+    And I press "Save and return to course"
+    And "Test forum name" activity should be hidden
+    And I turn editing mode off
+    And "Test forum name" activity should be hidden
     And I log out
+    # Student should not see this activity.
     And I log in as "student1"
     And I follow "Course 1"
-    And "Test forum name" activity should be hidden
+    And I should not see "Test forum name"
+    And I log out
 
   @javascript
-  Scenario: Activities can be shown and hidden inside a hidden section
+  Scenario: Activities can be made available and unavailable inside a hidden section
     Given the following "users" exist:
       | username | firstname | lastname | email |
       | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
     And the following "courses" exist:
       | fullname | shortname | format | numsections |
       | Course 1 | C1 | topics | 2 |
     And the following "course enrolments" exist:
       | user | course | role |
       | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
     And I log in as "teacher1"
     And I follow "Course 1"
     And I turn editing mode on
+    And I add the "Recent activity" block
     And I add a "Forum" to section "2" and I fill the form with:
       | Forum name | Test forum name |
       | Description | Test forum description |
@@ -61,11 +88,39 @@ Feature: Toggle activities visibility from the course page
     When I hide section "2"
     Then "Test forum name" activity should be hidden
     And I open "Test forum name" actions menu
-    And I click on "Show" "link" in the "Test forum name" activity
-    And "Test forum name" activity should be visible
+    And "Test forum name" actions menu should not have "Show" item
+    And "Test forum name" actions menu should not have "Hide" item
+    And "Test forum name" actions menu should not have "Make unavailable" item
+    And I click on "Make available" "link" in the "Test forum name" activity
+    And "Test forum name" activity should be available but hidden from course page
     And I open "Test forum name" actions menu
-    And I click on "Hide" "link" in the "Test forum name" activity
+    And "Test forum name" actions menu should not have "Show" item
+    And "Test forum name" actions menu should not have "Hide" item
+    And "Test forum name" actions menu should not have "Make available" item
+    And I click on "Make unavailable" "link" in the "Test forum name" activity
+    And "Test forum name" activity should be hidden
+    # Make sure that "Visible" dropdown in the edit menu has three options.
+    And I open "Test forum name" actions menu
+    And I click on "Edit settings" "link" in the "Test forum name" activity
+    And I expand all fieldsets
+    And the "Visible" select box should contain "Hidden from students"
+    And the "Visible" select box should contain "Available but not displayed on course page"
+    And the "Visible" select box should not contain "Hide"
+    And the "Visible" select box should not contain "Show"
+    And I set the field "Visible" to "Available but not displayed on course page"
+    And I press "Save and return to course"
+    And "Test forum name" activity should be available but hidden from course page
+    And I turn editing mode off
+    And "Test forum name" activity should be available but hidden from course page
+    And I log out
+    # Student will not see the module on the course page but can access it from other reports and blocks:
+    And I log in as "student1"
+    And I follow "Course 1"
     And "Test forum name" activity should be hidden
+    And I click on "Test forum name" "link" in the "Recent activity" "block"
+    And I should see "Test forum name"
+    And I should see "(There are no discussion topics yet in this forum)"
+    And I log out
 
   @javascript
   Scenario: Activities can be shown and hidden inside an orphaned section
@@ -88,8 +143,74 @@ Feature: Toggle activities visibility from the course page
     When I click on ".reduce-sections" "css_element"
     Then "Test forum name" activity should be visible
     And I open "Test forum name" actions menu
+    And "Test forum name" actions menu should not have "Show" item
+    And "Test forum name" actions menu should not have "Make available" item
+    And "Test forum name" actions menu should not have "Make unavailable" item
     And I click on "Hide" "link" in the "Test forum name" activity
     And "Test forum name" activity should be hidden
     And I open "Test forum name" actions menu
+    And "Test forum name" actions menu should not have "Hide" item
+    And "Test forum name" actions menu should not have "Make available" item
+    And "Test forum name" actions menu should not have "Make unavailable" item
     And I click on "Show" "link" in the "Test forum name" activity
     And "Test forum name" activity should be visible
+
+  @javascript
+  Scenario: Activities can be made available but not visible on a course page
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format | numsections |
+      | Course 1 | C1        | topics | 2           |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | allowstealth | 1 |
+    And I log out
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    And I turn editing mode on
+    And I add the "Recent activity" block
+    And I add a "Assignment" to section "2" and I fill the form with:
+      | Assignment name | Test assignment name |
+      | Description | Test assignment description |
+      | Visible | Show |
+    When I open "Test assignment name" actions menu
+    Then "Test assignment name" actions menu should not have "Show" item
+    And "Test assignment name" actions menu should have "Hide" item
+    And "Test assignment name" actions menu should not have "Make available" item
+    And "Test assignment name" actions menu should not have "Make unavailable" item
+    And I click on "Hide" "link" in the "Test assignment name" activity
+    And "Test assignment name" activity should be hidden
+    And I open "Test assignment name" actions menu
+    And "Test assignment name" actions menu should have "Show" item
+    And "Test assignment name" actions menu should not have "Hide" item
+    And "Test assignment name" actions menu should not have "Make unavailable" item
+    And I click on "Make available" "link" in the "Test assignment name" activity
+    And "Test assignment name" activity should be available but hidden from course page
+    # Make sure that "Visible" dropdown in the edit menu has three options.
+    And I open "Test assignment name" actions menu
+    And I click on "Edit settings" "link" in the "Test assignment name" activity
+    And I expand all fieldsets
+    And the "Visible" select box should contain "Show"
+    And the "Visible" select box should contain "Hidden from students"
+    And the "Visible" select box should not contain "Hide"
+    And the field "Visible" matches value "Available but not displayed on course page"
+    And I press "Save and return to course"
+    And "Test assignment name" activity should be available but hidden from course page
+    And I turn editing mode off
+    And "Test assignment name" activity should be available but hidden from course page
+    And I log out
+    # Student will not see the module on the course page but can access it from other reports and blocks:
+    And I log in as "student1"
+    And I follow "Course 1"
+    And "Test assignment name" activity should be hidden
+    And I click on "Test assignment name" "link" in the "Recent activity" "block"
+    And I should see "Test assignment name"
+    And I should see "Submission status"
+    And I log out
index df95cce..237bc7f 100644 (file)
@@ -623,9 +623,67 @@ class behat_course extends behat_base {
                 // All ok.
             }
 
-            // The 'Hide' button should be available.
-            $nohideexception = new ExpectationException('"' . $activityname . '" don\'t have a "' . get_string('hide') . '" icon', $this->getSession());
-            $this->find('named_partial', array('link', get_string('hide')), $nohideexception, $activitynode);
+            // Additional check if this is a teacher in editing mode.
+            if ($this->is_editing_on()) {
+                // The 'Hide' button should be available.
+                $nohideexception = new ExpectationException('"' . $activityname . '" doesn\'t have a "' .
+                    get_string('hide') . '" icon', $this->getSession());
+                $this->find('named_partial', array('link', get_string('hide')), $nohideexception, $activitynode);
+            }
+        }
+    }
+
+    /**
+     * Checks that the specified activity is visible. You need to be in the course page.
+     * It can be used being logged as a student and as a teacher on editing mode.
+     *
+     * @Then /^"(?P<activity_or_resource_string>(?:[^"]|\\")*)" activity should be available but hidden from course page$/
+     * @param string $activityname
+     * @throws ExpectationException
+     */
+    public function activity_should_be_available_but_hidden_from_course_page($activityname) {
+
+        if ($this->is_course_editor()) {
+
+            // The activity must exists and be visible.
+            $activitynode = $this->get_activity_node($activityname);
+
+            // The activity should not be dimmed.
+            try {
+                $xpath = "/descendant-or-self::a[contains(concat(' ', normalize-space(@class), ' '), ' dimmed ')] | " .
+                    "/descendant-or-self::div[contains(concat(' ', normalize-space(@class), ' '), ' dimmed_text ')]";
+                $this->find('xpath', $xpath, false, $activitynode);
+                throw new ExpectationException('"' . $activityname . '" is hidden', $this->getSession());
+            } catch (ElementNotFoundException $e) {
+                // All ok.
+            }
+
+            // Should has "stealth" class.
+            $exception = new ExpectationException('"' . $activityname . '" does not have CSS class "stealth"', $this->getSession());
+            $xpath = "/descendant-or-self::a[contains(concat(' ', normalize-space(@class), ' '), ' stealth ')]";
+            $this->find('xpath', $xpath, $exception, $activitynode);
+
+            // Additional check if this is a teacher in editing mode.
+            if ($this->is_editing_on()) {
+                // Also has either 'Hide' or 'Make unavailable' edit control.
+                $nohideexception = new ExpectationException('"' . $activityname . '" has neither "' . get_string('hide') .
+                    '" nor "' . get_string('makeunavailable') . '" icons', $this->getSession());
+                try {
+                    $this->find('named_partial', array('link', get_string('hide')), false, $activitynode);
+                } catch (ElementNotFoundException $e) {
+                    $this->find('named_partial', array('link', get_string('makeunavailable')), $nohideexception, $activitynode);
+                }
+            }
+
+        } else {
+
+            // Student should not see the activity at all.
+            try {