diff --git a/.grunt/jsdoc/README.md b/.grunt/jsdoc/README.md index 338303ca01ff5..3e8344a71df3e 100644 --- a/.grunt/jsdoc/README.md +++ b/.grunt/jsdoc/README.md @@ -16,4 +16,4 @@ Moodle - the world's open source learning platform This generated documentation includes API documentation for JavaScript written in the AMD and ES2015 module formats within Moodle. ## Related information -See [https://docs.moodle.org/dev](https://docs.moodle.org/dev) for other related Developer Documentation. +See [https://moodledev.io](https://moodledev.io) for other related Developer Documentation. diff --git a/.grunt/jsdoc/jsdoc.conf.js b/.grunt/jsdoc/jsdoc.conf.js index 240cbe6b16a2f..29684651a1df6 100644 --- a/.grunt/jsdoc/jsdoc.conf.js +++ b/.grunt/jsdoc/jsdoc.conf.js @@ -114,7 +114,7 @@ module.exports = { ], "menu": { "Developer Docs": { - href: "https://docs.moodle.org/dev", + href: "https://moodledev.io", target: "_blank", "class": "menu-item", id: "devdocs" diff --git a/CONTRIBUTING.txt b/CONTRIBUTING.txt index cfadb71b13d01..c94b370310983 100644 --- a/CONTRIBUTING.txt +++ b/CONTRIBUTING.txt @@ -6,7 +6,7 @@ of developers, designers, teachers, testers, translators and other users. We work in universities, schools, companies and other places. You are very welcome to join us and contribute to the project. -See for the many ways you +See for the many ways you can help, not only with coding. Moodle is open to community contributions to core, though all code must go @@ -35,7 +35,7 @@ submitted patches has evolved. * New features are developed on the master branch. Bug fixes are also backported to currently supported maintenance (stable) branches. -For further details, see . +For further details, see . Moodle plugins -------------- @@ -53,4 +53,4 @@ be easily installed and updated via the Moodle administration interface. the plugins directory. We do not pull from your code repository; you must do it explicitly. -For further details, see . +For further details, see . diff --git a/README.txt b/README.txt index 729dbe4b4f2d8..15655959e1b7c 100644 --- a/README.txt +++ b/README.txt @@ -20,7 +20,7 @@ Moodle is widely used around the world by universities, schools, companies and all manner of organisations and individuals. Moodle is provided freely as open source software, under the GNU General Public -License . +License . Moodle is written in PHP and JavaScript and uses an SQL database for storing the data. diff --git a/admin/localplugins.php b/admin/localplugins.php index eb6862d0d799d..1d6e8f4dca63d 100644 --- a/admin/localplugins.php +++ b/admin/localplugins.php @@ -21,7 +21,7 @@ * Displays the list of found local plugins, their version (if found) and * a link to delete the local plugin. * - * @see http://docs.moodle.org/dev/Local_customisation + * @see https://moodledev.io/docs/apis/plugintypes/local * @package admin * @copyright 2010 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later diff --git a/admin/plagiarism.php b/admin/plagiarism.php index d0a3f5f8372f8..fcb381846a648 100644 --- a/admin/plagiarism.php +++ b/admin/plagiarism.php @@ -20,7 +20,7 @@ * Displays the list of found plagiarism plugins, their version (if found) and * a link to uninstall the plagiarism plugin. * - * @see http://docs.moodle.org/dev/Plagiarism_API + * @see https://moodledev.io/docs/apis/subsystems/plagiarism * @package admin * @copyright 2012 Dan Marsden * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later diff --git a/admin/renderer.php b/admin/renderer.php index 30365a1774106..0d9683e6b23ba 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -44,7 +44,7 @@ public function install_licence_page() { $output .= $this->heading(get_string('copyrightnotice')); $output .= $this->box($copyrightnotice, 'copyrightnotice'); $output .= html_writer::empty_tag('br'); - $output .= $this->confirm(get_string('doyouagree'), $continue, "http://docs.moodle.org/dev/License"); + $output .= $this->confirm(get_string('doyouagree'), $continue, "https://moodledev.io/general/license"); $output .= $this->footer(); return $output; @@ -746,10 +746,10 @@ protected function moodle_copyright() { ////////////////////////////////////////////////////////////////////////////////////////////////// //// IT IS ILLEGAL AND A VIOLATION OF THE GPL TO HIDE, REMOVE OR MODIFY THIS COPYRIGHT NOTICE /// $copyrighttext = 'Moodle '. - ''.$CFG->release.'
'. + ''.$CFG->release.'
'. 'Copyright © 1999 onwards, Martin Dougiamas
'. 'and many other contributors.
'. - 'GNU Public License'; + 'GNU Public License'; ////////////////////////////////////////////////////////////////////////////////////////////////// return $this->box($copyrighttext, 'copyright'); @@ -993,7 +993,7 @@ protected function moodle_available_update_info(\core\update\info $updateinfo) { * @return string HTML to output. */ protected function release_notes_link() { - $releasenoteslink = get_string('releasenoteslink', 'admin', 'http://docs.moodle.org/dev/Releases'); + $releasenoteslink = get_string('releasenoteslink', 'admin', 'https://moodledev.io/general/releases'); $releasenoteslink = str_replace('target="_blank"', 'onclick="this.target=\'_blank\'"', $releasenoteslink); // extremely ugly validation hack return $this->box($releasenoteslink, 'generalbox alert alert-info'); } diff --git a/admin/tool/componentlibrary/content/library/moodle-templates.md b/admin/tool/componentlibrary/content/library/moodle-templates.md index da71004b699f7..3502219d60681 100644 --- a/admin/tool/componentlibrary/content/library/moodle-templates.md +++ b/admin/tool/componentlibrary/content/library/moodle-templates.md @@ -9,7 +9,7 @@ menu: "main" ## Moodle templates -[Moodle templates](https://docs.moodle.org/dev/Templates) are use to write HTML and Javascript using mustache files. +[Moodle templates](https://moodledev.io/docs/guides/templates) are use to write HTML and Javascript using mustache files. If you are creating your own pages in the UI Component library you can load core templates using this (shortcode) syntax: @@ -51,4 +51,3 @@ This is the result of adding the core/notification template on this page: "extraclasses": "foo bar" } {{< /mustache >}} - diff --git a/admin/tool/componentlibrary/content/moodle/components/form-elements.md b/admin/tool/componentlibrary/content/moodle/components/form-elements.md index fd87e851c1342..93e4fc6cb0495 100644 --- a/admin/tool/componentlibrary/content/moodle/components/form-elements.md +++ b/admin/tool/componentlibrary/content/moodle/components/form-elements.md @@ -11,7 +11,7 @@ tags: ## How to use moodle forms -Forms are constructed using lib/formslib.php. Using the addElement methods in php a set of different form element types can be added to a form. For more info visit the [Moodledocs](https://docs.moodle.org/dev/lib/formslib.php_Form_Definition) page for forms +Forms are constructed using lib/formslib.php. Using the addElement methods in php a set of different form element types can be added to a form. For more info visit the [Moodledocs](https://moodledev.io/docs/apis/subsystems/form) page for forms {{< php >}} $mform->addElement('button', 'intro', get_string("buttonlabel")); diff --git a/admin/tool/componentlibrary/content/moodle/components/toggle.md b/admin/tool/componentlibrary/content/moodle/components/toggle.md index f3194318d59c2..3e896d9a54357 100644 --- a/admin/tool/componentlibrary/content/moodle/components/toggle.md +++ b/admin/tool/componentlibrary/content/moodle/components/toggle.md @@ -61,7 +61,7 @@ Disabled toggle with extra classes. ## Use toggle as a template block -It is also possible to include *core/toggle* in any other template using [blocks](https://docs.moodle.org/dev/Templates#Blocks), instead of rendering it with a context. +It is also possible to include *core/toggle* in any other template using [blocks](https://moodledev.io/docs/guides/templates#blocks), instead of rendering it with a context. The parameters that you can define are: * id: Unique id for the toggle input. * extraclasses: Any extra classes added to the toggle input outer container. diff --git a/admin/tool/componentlibrary/content/moodle/javascript/confirm.md b/admin/tool/componentlibrary/content/moodle/javascript/confirm.md index 091ebf5bc988b..d2d4944d7b206 100644 --- a/admin/tool/componentlibrary/content/moodle/javascript/confirm.md +++ b/admin/tool/componentlibrary/content/moodle/javascript/confirm.md @@ -16,7 +16,7 @@ to the element that will trigger the confirmation modal. ## Source files -* `lib/amd/src/confirm.js` ({{< jsdoc module="core/confirm" >}}) +* `lib/amd/src/utility.js` ({{< jsdoc module="core/utility" >}}) * `lib/templates/modal.mustache` ## Usage @@ -24,7 +24,7 @@ The confirmation AMD module is loaded automatically, so the only thing you need to the target element: {{< highlight html >}} +data-confirmation-content-str='["areyousure"]' data-confirmation-yes-button-str='["delete", "core"]'>Show confirmation modal {{< /highlight >}} You can also use it on PHP, you just need to set the attributes parameter to any moodle output component that takes attributes: @@ -32,7 +32,7 @@ You can also use it on PHP, you just need to set the attributes parameter to any echo $OUTPUT->single_button('#', get_string('delete'), 'get', [ 'data-confirmation' => 'modal', 'data-confirmation-title-str' => json_encode(['delete', 'core']), - 'data-confirmation-question-str' => json_encode(['areyousure']), + 'data-confirmation-content-str' => json_encode(['areyousure']), 'data-confirmation-yes-button-str' => json_encode(['delete', 'core']) ]); {{< / php >}} @@ -56,8 +56,8 @@ echo $OUTPUT->single_button('#', get_string('delete'), 'get', [ The modal title language string identifier, must be provided in JSON encoded format. - data-confirmation-question-str - The confirmation question language string identifier, must be provided in JSON encoded format. + data-confirmation-content-str + The confirmation modal main content language string identifier, must be provided in JSON encoded format. data-confirmation-yes-button-str @@ -84,14 +84,14 @@ echo $OUTPUT->single_button('#', get_string('delete'), 'get', [ {{< example >}} +data-confirmation-content-str='["areyousure"]' data-confirmation-yes-button-str='["delete", "core"]'>Show confirmation modal {{< /example >}} ### Confirmation modal with a toast {{< example >}} {{< /example >}} @@ -99,6 +99,6 @@ data-confirmation-toast-confirmation-str='["deleteblockinprogress", "block", "On {{< example >}} {{< /example >}} diff --git a/admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php b/admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php index 2a004d9d14965..6366df4b21071 100644 --- a/admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php +++ b/admin/tool/dataprivacy/tests/behat/behat_tool_dataprivacy.php @@ -58,7 +58,7 @@ class behat_tool_dataprivacy extends behat_base { ); /** - * Creates the specified element. More info about available elements in http://docs.moodle.org/dev/Acceptance_testing#Fixtures. + * Creates the specified element. More info about available elements in https://moodledev.io/general/development/tools/behat. * * @Given /^the following data privacy "(?P(?:[^"]|\\")*)" exist:$/ * diff --git a/admin/tool/licensemanager/tests/behat/license_manager.feature b/admin/tool/licensemanager/tests/behat/license_manager.feature index 5ab86353b30db..e85890f644128 100644 --- a/admin/tool/licensemanager/tests/behat/license_manager.feature +++ b/admin/tool/licensemanager/tests/behat/license_manager.feature @@ -10,12 +10,12 @@ Feature: Licence manager Then I should see "Licence not specified" in the "unknown" "table_row" And I should see "All rights reserved" in the "allrightsreserved" "table_row" And I should see "Public domain" in the "public" "table_row" - And I should see "Creative Commons" in the "cc" "table_row" - And I should see "Creative Commons - NoDerivs" in the "cc-nd" "table_row" - And I should see "Creative Commons - No Commercial NoDerivs" in the "cc-nc-nd" "table_row" - And I should see "Creative Commons - No Commercial" in the "cc-nc" "table_row" - And I should see "Creative Commons - No Commercial ShareAlike" in the "cc-nc-sa" "table_row" - And I should see "Creative Commons - ShareAlike" in the "cc-sa" "table_row" + And I should see "Creative Commons - 4.0 International" in the "cc-4.0" "table_row" + And I should see "Creative Commons - NoDerivatives 4.0 International" in the "cc-nd-4.0" "table_row" + And I should see "Creative Commons - NonCommercial-NoDerivatives 4.0 International" in the "cc-nc-nd-4.0" "table_row" + And I should see "Creative Commons - NonCommercial-ShareAlike 4.0 International" in the "cc-nc-sa-4.0" "table_row" + And I should see "Creative Commons - ShareAlike 4.0 International" in the "cc-sa-4.0" "table_row" + And I should see "Creative Commons - NonCommercial 4.0 International" in the "cc-nc-4.0" "table_row" Scenario: I should be able to enable and disable licences Given I log in as "admin" @@ -25,25 +25,25 @@ Feature: Licence manager And I navigate to "Licence > Licence manager" in site administration Then "This is the site default licence" "icon" should exist in the "public" "table_row" And "Enable licence" "icon" should not exist in the "public" "table_row" - And "This is the site default licence" "icon" should not exist in the "cc" "table_row" + And "This is the site default licence" "icon" should not exist in the "cc-4.0" "table_row" And I navigate to "Licence > Licence settings" in site administration And I set the field "Default site licence" to "Creative Commons" And I press "Save changes" And I navigate to "Licence > Licence manager" in site administration - And "This is the site default licence" "icon" should exist in the "cc" "table_row" - And "Enable licence" "icon" should not exist in the "cc" "table_row" + And "This is the site default licence" "icon" should exist in the "cc-4.0" "table_row" + And "Enable licence" "icon" should not exist in the "cc-4.0" "table_row" And "This is the site default licence" "icon" should not exist in the "public" "table_row" @javascript @_file_upload Scenario Outline: User licence preference is remembered depending of setting value Given the following config values are set as admin: - | sitedefaultlicense | cc | + | sitedefaultlicense | cc-4.0 | | rememberuserlicensepref | | And I log in as "admin" And I follow "Private files" in the user menu And I follow "Add..." And I follow "Upload a file" - And the field with xpath "//select[@name='license']" matches value "Creative Commons" + And the field with xpath "//select[@name='license']" matches value "Creative Commons - 4.0 International" And I click on "Close" "button" in the "File picker" "dialogue" When I upload "lib/tests/fixtures/empty.txt" file to "Files" filemanager as: | Save as | empty_copy.txt | @@ -53,6 +53,6 @@ Feature: Licence manager Then the field with xpath "//select[@name='license']" matches value "" Examples: - | rememberuserlicensepref | expectedlicence | - | 0 | Creative Commons | - | 1 | Public domain | + | rememberuserlicensepref | expectedlicence | + | 0 | Creative Commons - 4.0 International | + | 1 | Public domain | diff --git a/admin/tool/licensemanager/tests/manager_test.php b/admin/tool/licensemanager/tests/manager_test.php index e86cf68d633b5..2b9763c4ed492 100644 --- a/admin/tool/licensemanager/tests/manager_test.php +++ b/admin/tool/licensemanager/tests/manager_test.php @@ -174,24 +174,24 @@ public function test_change_license_order() { $this->resetAfterTest(); $licenseorder = array_keys(license_manager::get_licenses()); - $initialposition = array_search('cc-nc', $licenseorder); + $initialposition = array_search('cc-nc-4.0', $licenseorder); $manager = new tool_licensemanager\manager(); // We're testing a private method, so we need to setup reflector magic. $method = new ReflectionMethod('\tool_licensemanager\manager', 'change_license_order'); $method->setAccessible(true); // Allow accessing of private method. - $method->invoke($manager, \tool_licensemanager\manager::ACTION_MOVE_UP, 'cc-nc'); + $method->invoke($manager, \tool_licensemanager\manager::ACTION_MOVE_UP, 'cc-nc-4.0'); $licenseorder = array_keys(license_manager::get_licenses()); - $newposition = array_search('cc-nc', $licenseorder); + $newposition = array_search('cc-nc-4.0', $licenseorder); $this->assertLessThan($initialposition, $newposition); $initialposition = array_search('allrightsreserved', $licenseorder); $method->invoke($manager, \tool_licensemanager\manager::ACTION_MOVE_DOWN, 'allrightsreserved'); $licenseorder = array_keys(license_manager::get_licenses()); - $newposition = array_search('cc-nc', $licenseorder); + $newposition = array_search('cc-nc-4.0', $licenseorder); $this->assertGreaterThan($initialposition, $newposition); } diff --git a/admin/tool/lp/tests/behat/behat_tool_lp_data_generators.php b/admin/tool/lp/tests/behat/behat_tool_lp_data_generators.php index 166b4c5509a39..1f30e9c5bfa41 100644 --- a/admin/tool/lp/tests/behat/behat_tool_lp_data_generators.php +++ b/admin/tool/lp/tests/behat/behat_tool_lp_data_generators.php @@ -94,7 +94,7 @@ class behat_tool_lp_data_generators extends behat_base { ); /** - * Creates the specified element. More info about available elements in http://docs.moodle.org/dev/Acceptance_testing#Fixtures. + * Creates the specified element. More info about available elements in https://moodledev.io/general/development/tools/behat. * * @Given /^the following lp "(?P(?:[^"]|\\")*)" exist:$/ * diff --git a/admin/tool/messageinbound/classes/manager.php b/admin/tool/messageinbound/classes/manager.php index 4864bef802996..7542e1d8c5ae2 100644 --- a/admin/tool/messageinbound/classes/manager.php +++ b/admin/tool/messageinbound/classes/manager.php @@ -829,7 +829,7 @@ private function is_bulk_message( // An auto-reply may itself include the Bulk Precedence. $precedence = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('Precedence'); - $isbulk = $isbulk || strtolower($precedence) == 'bulk'; + $isbulk = $isbulk || strtolower($precedence ?? '') == 'bulk'; // If the X-Autoreply header is set, and not 'no', then this is an automatic reply. $autoreply = $messagedata->getHeaderText(0, \Horde_Imap_Client_Data_Fetch::HEADER_PARSE)->getValue('X-Autoreply'); diff --git a/admin/tool/moodlenet/tests/local/remote_resource_test.php b/admin/tool/moodlenet/tests/local/remote_resource_test.php index 500f95af4b2eb..f3195fb7a9c3b 100644 --- a/admin/tool/moodlenet/tests/local/remote_resource_test.php +++ b/admin/tool/moodlenet/tests/local/remote_resource_test.php @@ -98,7 +98,15 @@ public function test_network_features() { ] ); - $this->assertGreaterThan(0, $remoteres->get_download_size()); + // We need to handle size of -1 (missing "Content-Length" header), or where it is set and greater than zero. + $this->assertThat( + $remoteres->get_download_size(), + $this->logicalOr( + $this->equalTo(-1), + $this->greaterThan(0), + ), + ); + [$path, $name] = $remoteres->download_to_requestdir(); $this->assertIsString($path); $this->assertEquals('test.html', $name); diff --git a/admin/tool/uploadcourse/classes/tracker.php b/admin/tool/uploadcourse/classes/tracker.php index 0daa84527c5cc..860c84cc5d87d 100644 --- a/admin/tool/uploadcourse/classes/tracker.php +++ b/admin/tool/uploadcourse/classes/tracker.php @@ -173,13 +173,16 @@ public function output($line, $outcome, $status, $data) { } else { $outcome = $OUTPUT->pix_icon('i/invalid', ''); } + echo html_writer::start_tag('tr', array('class' => 'r' . $this->rownb % 2)); echo html_writer::tag('td', $line, array('class' => 'c' . $ci++)); echo html_writer::tag('td', $outcome, array('class' => 'c' . $ci++)); echo html_writer::tag('td', isset($data['id']) ? $data['id'] : '', array('class' => 'c' . $ci++)); - echo html_writer::tag('td', isset($data['shortname']) ? $data['shortname'] : '', array('class' => 'c' . $ci++)); - echo html_writer::tag('td', isset($data['fullname']) ? $data['fullname'] : '', array('class' => 'c' . $ci++)); - echo html_writer::tag('td', isset($data['idnumber']) ? $data['idnumber'] : '', array('class' => 'c' . $ci++)); + + // Ensure our data is suitable for HTML output. + echo html_writer::tag('td', isset($data['shortname']) ? s($data['shortname']) : '', array('class' => 'c' . $ci++)); + echo html_writer::tag('td', isset($data['fullname']) ? s($data['fullname']) : '', array('class' => 'c' . $ci++)); + echo html_writer::tag('td', isset($data['idnumber']) ? s($data['idnumber']) : '', array('class' => 'c' . $ci++)); echo html_writer::tag('td', $status, array('class' => 'c' . $ci++)); echo html_writer::end_tag('tr'); } diff --git a/auth/oauth2/confirm-account.php b/auth/oauth2/confirm-account.php index 955f44939ebba..d5ecd4d344b2f 100644 --- a/auth/oauth2/confirm-account.php +++ b/auth/oauth2/confirm-account.php @@ -40,7 +40,7 @@ $confirmed = $auth->user_confirm($username, $usersecret); -if ($confirmed == AUTH_CONFIRM_ALREADY) { +if ($confirmed == AUTH_CONFIRM_ALREADY && !isloggedin()) { $user = get_complete_user_data('username', $username); $PAGE->navbar->add(get_string("alreadyconfirmed")); $PAGE->set_title(get_string("alreadyconfirmed")); @@ -61,11 +61,7 @@ throw new \moodle_exception('cannotfinduser', '', '', s($username)); } - if (!$user->suspended) { - complete_user_login($user); - - \core\session\manager::apply_concurrent_login_limit($user->id, session_id()); - + if ($user->id == $USER->id) { // Check where to go, $redirect has a higher preference. if (empty($redirect) and !empty($SESSION->wantsurl) ) { $redirect = $SESSION->wantsurl; @@ -82,14 +78,20 @@ $PAGE->set_heading($COURSE->fullname); echo $OUTPUT->header(); echo $OUTPUT->box_start('generalbox centerpara boxwidthnormal boxaligncenter'); - echo "

".get_string("thanks").", ". fullname($USER) . "

\n"; + echo "

".get_string("thanks").", ". fullname($user) . "

\n"; echo "

".get_string("confirmed")."

\n"; - echo $OUTPUT->single_button("$CFG->wwwroot/course/", get_string('courses')); + if (!isloggedin() || isguestuser()) { + echo $OUTPUT->single_button(get_login_url(), get_string('login')); + } else { + echo $OUTPUT->single_button("$CFG->wwwroot/login/logout.php", get_string('logout')); + } echo $OUTPUT->box_end(); echo $OUTPUT->footer(); exit; } else { - \core\notification::error(get_string('confirmationinvalid', 'auth_oauth2')); + if (!isloggedin()) { + \core\notification::error(get_string('confirmationinvalid', 'auth_oauth2')); + } } redirect("$CFG->wwwroot/"); diff --git a/auth/upgrade.txt b/auth/upgrade.txt index 79a3b80d4be9a..1b0a62202fca5 100644 --- a/auth/upgrade.txt +++ b/auth/upgrade.txt @@ -21,7 +21,7 @@ information provided here is intended especially for developers. * The auth_db and auth_ldap plugins' implementations of update_user_record() have been removed and both now call the new implementation added in the base class. * Self registration plugins should use core_privacy\local\sitepolicy\manager instead of directly checking - $CFG->sitepolicy , especially in custom signup forms. See https://docs.moodle.org/dev/Site_policy_handler + $CFG->sitepolicy , especially in custom signup forms. === 3.3 === diff --git a/availability/condition/completion/classes/condition.php b/availability/condition/completion/classes/condition.php index 8503ef69ecc64..2db6cca0c6cb1 100644 --- a/availability/condition/completion/classes/condition.php +++ b/availability/condition/completion/classes/condition.php @@ -149,15 +149,23 @@ public function is_available($not, info $info, $grabthelot, $userid): bool { $allow = true; if ($this->expectedcompletion == COMPLETION_COMPLETE) { - // Complete also allows the pass, fail states. + // Complete also allows the pass state. switch ($completiondata->completionstate) { case COMPLETION_COMPLETE: - case COMPLETION_COMPLETE_FAIL: case COMPLETION_COMPLETE_PASS: break; default: $allow = false; } + } else if ($this->expectedcompletion == COMPLETION_INCOMPLETE) { + // Incomplete also allows the fail state. + switch ($completiondata->completionstate) { + case COMPLETION_INCOMPLETE: + case COMPLETION_COMPLETE_FAIL: + break; + default: + $allow = false; + } } else { // Other values require exact match. if ($completiondata->completionstate != $this->expectedcompletion) { diff --git a/availability/condition/completion/tests/behat/availability_completion.feature b/availability/condition/completion/tests/behat/availability_completion.feature index 0f88b464c6151..e43ee4c9650a3 100644 --- a/availability/condition/completion/tests/behat/availability_completion.feature +++ b/availability/condition/completion/tests/behat/availability_completion.feature @@ -74,3 +74,48 @@ Feature: availability_completion And I click on "forum 1" "link" in the "region-main" "region" And I am on "Course 1" course homepage And I should see "Page 2" in the "region-main" "region" + + @javascript + Scenario Outline: Restrict access for activity completion should display correctly + Given the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | truefalse | First question | Answer the first question | + And the following "activities" exist: + | activity | name | course | idnumber | gradepass | completion | completionpassgrade | completionusegrade | + | quiz | Test quiz name | C1 | quiz1 | 5.00 | 2 | 1 | 1 | + And quiz "Test quiz name" contains the following questions: + | question | page | + | First question | 1 | + And I am on the "Page 2" "page activity editing" page logged in as "teacher1" + And I expand all fieldsets + And I press "Add restriction..." + And I click on "Activity completion" "button" in the "Add restriction..." "dialogue" + And I click on ".availability-item .availability-eye img" "css_element" + And I set the following fields to these values: + | Required completion status | | + | cm | quiz | + And I press "Save and return to course" + And I am on the "Course 1" "course" page logged in as "student1" + And I see "Page 2" in the "region-main" "region" + # Failed grade for quiz. + When user "student1" has attempted "Test quiz name" with responses: + | slot | response | + | 1 | | + And I reload the page + And I see "Page 2" in the "region-main" "region" + # Passing grade for quiz. + But user "student1" has attempted "Test quiz name" with responses: + | slot | response | + | 1 | | + And I reload the page + And I see "Page 2" in the "region-main" "region" + + Examples: + | condition | answer1 | answer2 | shouldornot | shouldornotanswer1 | shouldornotanswer2 | + | must be marked complete | False | True | should not | should not | should | + | must not be marked complete | False | True | should | should | should not | + | must be complete with pass grade | False | True | should not | should not | should | + | must be complete with fail grade | False | True | should not | should | should not | diff --git a/availability/condition/completion/tests/condition_test.php b/availability/condition/completion/tests/condition_test.php index 1ee3a67b9e78e..d76857fdf310b 100644 --- a/availability/condition/completion/tests/condition_test.php +++ b/availability/condition/completion/tests/condition_test.php @@ -301,14 +301,14 @@ public function test_usage() { $cond = new condition((object)[ 'cm' => (int)$assigncm->id, 'e' => COMPLETION_INCOMPLETE ]); - $this->assertFalse($cond->is_available(false, $info, true, $user->id)); - $this->assertTrue($cond->is_available(true, $info, true, $user->id)); + $this->assertTrue($cond->is_available(false, $info, true, $user->id)); + $this->assertFalse($cond->is_available(true, $info, true, $user->id)); $cond = new condition((object)[ 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE ]); - $this->assertTrue($cond->is_available(false, $info, true, $user->id)); - $this->assertFalse($cond->is_available(true, $info, true, $user->id)); + $this->assertFalse($cond->is_available(false, $info, true, $user->id)); + $this->assertTrue($cond->is_available(true, $info, true, $user->id)); $cond = new condition((object)[ 'cm' => (int)$assigncm->id, 'e' => COMPLETION_COMPLETE_PASS @@ -531,10 +531,10 @@ public function previous_activity_data(): array { ], // Depending on assign with grade. 'Previous complete condition with previous fail grade' => [ - 40, COMPLETION_COMPLETE, '', 'page3', true, false, '~Assign!.*is marked complete~' + 40, COMPLETION_COMPLETE, '', 'page3', false, true, '~Assign!.*is marked complete~', ], 'Previous incomplete condition with previous fail grade' => [ - 40, COMPLETION_INCOMPLETE, '', 'page3', false, true, '~Assign!.*is incomplete~' + 40, COMPLETION_INCOMPLETE, '', 'page3', true, false, '~Assign!.*is incomplete~', ], 'Previous complete pass condition with previous fail grade' => [ 40, COMPLETION_COMPLETE_PASS, '', 'page3', false, true, '~Assign!.*is complete and passed~' diff --git a/availability/condition/date/tests/behat/availability_date_conflict.feature b/availability/condition/date/tests/behat/availability_date_conflict.feature new file mode 100644 index 0000000000000..e9e2429167a81 --- /dev/null +++ b/availability/condition/date/tests/behat/availability_date_conflict.feature @@ -0,0 +1,105 @@ +@availability @availability_date @javascript +Feature: As a teacher I can set availability dates restriction to an activity and see a warning when conflicting dates are set + + Background: + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | + | teacher1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "activities" exist: + | activity | name | intro | introformat | course | content | contentformat | idnumber | + | page | PageName1 | PageDesc1 | 1 | C1 | Page 1 | 1 | 1 | + + Scenario: When I set dates to potential conflicting dates in the same subset, I should see a warning. + Given I am on the PageName1 "page activity editing" page logged in as teacher1 + And I expand all fieldsets + And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1" "availability_date > Date Restriction" to "4" + And I set the field "Direction" in the "1" "availability_date > Date Restriction" to "from" + And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "2" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "2" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "2" "availability_date > Date Restriction" to "6" + And I set the field "Direction" in the "2" "availability_date > Date Restriction" to "until" + And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "3" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "3" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "3" "availability_date > Date Restriction" to "6" + When I set the field "Direction" in the "3" "availability_date > Date Restriction" to "from" + Then I should see "Conflicts with other date restrictions" + + Scenario: If there are conflicting dates in the same subset, I should not see a warning if condition are separated by "any". + Given I am on the PageName1 "page activity editing" page logged in as teacher1 + And I expand all fieldsets + And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area" + And I click on "Restriction set" "button" in the "Add restriction..." "dialogue" + And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.1" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.1" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.1" "availability_date > Date Restriction" to "4" + And I set the field "Direction" in the "1.1" "availability_date > Date Restriction" to "from" + And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.2" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.2" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.2" "availability_date > Date Restriction" to "6" + And I set the field "Direction" in the "1.2" "availability_date > Date Restriction" to "until" + And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.3" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.3" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.3" "availability_date > Date Restriction" to "6" + And I set the field "Direction" in the "1.3" "availability_date > Date Restriction" to "from" + When I set the field "Required restrictions" in the "1" "core_availability > Set Of Restrictions" to "any" + Then I should not see "Conflicts with other date restrictions" + + Scenario: There should a conflicting availability dates are in the same subset separated by "all". + Given I am on the PageName1 "page activity editing" page logged in as teacher1 + And I expand all fieldsets + # Root level: Student "must" match the following. + And I click on "Add restriction..." "button" in the "root" "core_availability > Availability Button Area" + And I click on "Restriction set" "button" in the "Add restriction..." "dialogue" + # This is the second level: Student "must" match any of the following. + And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area" + And I click on "Restriction set" "button" in the "Add restriction..." "dialogue" + # And now the third and final level. + And I click on "Add restriction..." "button" in the "1.1" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.1.1" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.1.1" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.1.1" "availability_date > Date Restriction" to "2" + And I set the field "Direction" in the "1.1.1" "availability_date > Date Restriction" to "from" + And I click on "Add restriction..." "button" in the "1.1" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.1.2" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.1.2" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.1.2" "availability_date > Date Restriction" to "3" + And I set the field "Direction" in the "1.1.2" "availability_date > Date Restriction" to "until" + # Then add a restriction to the second level. + And I click on "Add restriction..." "button" in the "1" "core_availability > Availability Button Area" + And I click on "Restriction set" "button" in the "Add restriction..." "dialogue" + And I click on "Add restriction..." "button" in the "1.2" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.2.1" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.2.1" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.2.1" "availability_date > Date Restriction" to "4" + And I set the field "Direction" in the "1.2.1" "availability_date > Date Restriction" to "from" + And I click on "Add restriction..." "button" in the "1.2" "core_availability > Availability Button Area" + And I click on "Date" "button" in the "Add restriction..." "dialogue" + And I set the field "year" in the "1.2.2" "availability_date > Date Restriction" to "2023" + And I set the field "Month" in the "1.2.2" "availability_date > Date Restriction" to "April" + And I set the field "day" in the "1.2.2" "availability_date > Date Restriction" to "3" + When I set the field "Direction" in the "1.2.2" "availability_date > Date Restriction" to "until" + # Same subset, we can detect conflicts. + Then I should see "Conflicts with other date restrictions" diff --git a/availability/condition/date/tests/behat/behat_availability_date.php b/availability/condition/date/tests/behat/behat_availability_date.php new file mode 100644 index 0000000000000..f6740a3fbe967 --- /dev/null +++ b/availability/condition/date/tests/behat/behat_availability_date.php @@ -0,0 +1,39 @@ +. +use Behat\Mink\Element\NodeElement; + +/** + * Behat availabilty-related steps definitions. + * + * @package availability_date + * @category test + * @copyright 2023 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_availability_date extends behat_base { + /** + * Return the list of partial named selectors. + * + * @return array + */ + public static function get_partial_named_selectors(): array { + return [ + new behat_component_named_selector( + 'Date Restriction', ["//div[h3[@data-restriction-order=%locator% and contains(text(), 'Date restriction')]]"] + ), + ]; + } +} diff --git a/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-debug.js b/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-debug.js index e1ab688d539a9..5db031832e21c 100644 --- a/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-debug.js +++ b/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-debug.js @@ -65,6 +65,11 @@ M.availability_date.form.getNode = function(json) { // Set default time that corresponds to the HTML selectors. node.setData('time', this.defaultTime); } + if (json.nodeUID === undefined) { + var miliTime = new Date(); + json.nodeUID = miliTime.getTime(); + } + node.setData('nodeUID', json.nodeUID); if (json.d !== undefined) { node.one('select[name=direction]').set('value', json.d); } @@ -114,7 +119,7 @@ M.availability_date.form.getNode = function(json) { * gets an AJAX response. * * @method updateTime - * @param {Y.Node} component Node for plugin controls + * @param {Y.Node} node Node for plugin controls */ M.availability_date.form.updateTime = function(node) { // After a change to the date/time we need to recompute the @@ -140,39 +145,53 @@ M.availability_date.form.updateTime = function(node) { M.availability_date.form.fillValue = function(value, node) { value.d = node.one('select[name=direction]').get('value'); value.t = parseInt(node.getData('time'), 10); + value.nodeUID = node.getData('nodeUID'); }; /** - * List out Date node value in an array node. - * - * This will go through all array node and list from earlier date node to current date node. + * List out Date node value in the same branch. * - * @method convertTreeDateValue - * @param {array} tree Tree node to convert - * @param {array} arrayDateNode - * @param {array} currentNode current node. + * This will go through all array node and list nodes that are sibling of the current node. * - * @return {array} arrayDateNode + * @method findAllDateSiblings + * @param {Array} tree Tree items to convert + * @param {Number} nodeUIDToFind node UID to find. + * @return {Array|null} array of surrounding date avaiability values */ -M.availability_date.form.convertTreeDateValue = function(tree, arrayDateNode, currentNode) { - var shouldSkip = false; - tree.forEach(function(node) { - if (shouldSkip) { - return; - } - if (node.type === 'date') { - // We go through all tree node, if we meet the current node then return. - if (node.t === parseInt(currentNode.getData('time'), 10) - && currentNode.one('select[name=direction]').get('value') == node.d) { - shouldSkip = true; - return; +M.availability_date.form.findAllDateSiblings = function(tree, nodeUIDToFind) { + var itemValue = null; + var siblingsFinderRecursive = function(itemsTree) { + var dateSiblings = []; + var nodeFound = false; + var index; + var childDates; + var currentOp = itemsTree.op !== undefined ? itemsTree.op : null; + if (itemsTree.c !== undefined) { + var children = itemsTree.c; + for (index = 0; index < children.length; index++) { + itemValue = children.at(index); + if (itemValue.type === undefined) { + childDates = siblingsFinderRecursive(itemValue); + if (childDates) { + return childDates; + } + } + if (itemValue.type === 'date') { + // We go through all tree node, if we meet the current node then we add all nodes in the current branch. + if (nodeUIDToFind === itemValue.nodeUID) { + nodeFound = true; + } else if (currentOp === '&') { + dateSiblings.push(itemValue); + } + } + } + if (nodeFound) { + return dateSiblings; } - arrayDateNode.push(node); - } else if (node.type === undefined) { - M.availability_date.form.convertTreeDateValue(node.c, arrayDateNode, currentNode); } - }); - return arrayDateNode; + return null; + }; + return siblingsFinderRecursive(tree); }; /** @@ -181,37 +200,34 @@ M.availability_date.form.convertTreeDateValue = function(tree, arrayDateNode, cu * This will check current date node with all date node in tree node. * * @method checkConditionDate - * @param {array} currentNode The curent node. + * @param {Y.Node} currentNode The curent node. * * @return {boolean} error Return true if the date is conflict. */ M.availability_date.form.checkConditionDate = function(currentNode) { var error = false; - if (M.core_availability.form.rootList.getValue().op === '&') { - var jsValue = M.core_availability.form.rootList.getValue().c; - var arrayDateNode = M.availability_date.form.convertTreeDateValue(jsValue, [], currentNode); - var currentNodeDirection = currentNode.one('select[name=direction]').get('value'); - var currentNodeTime = parseInt(currentNode.getData('time'), 10); - arrayDateNode.forEach(function(checkNode) { + var currentNodeUID = currentNode.getData('nodeUID'); + var currentNodeDirection = currentNode.one('select[name=direction]').get('value'); + var currentNodeTime = parseInt(currentNode.getData('time'), 10); + var dateSiblings = M.availability_date.form.findAllDateSiblings( + M.core_availability.form.rootList.getValue(), + currentNodeUID); + if (dateSiblings) { + dateSiblings.forEach(function(dateSibling) { // Validate if the date is conflict. - if (checkNode.d === '<') { - if (currentNodeDirection === '>=' && currentNodeTime >= checkNode.t) { + if (dateSibling.d === '<') { + if (currentNodeDirection === '>=' && currentNodeTime >= dateSibling.t) { error = true; } } else { - if (currentNodeDirection === '<' && currentNodeTime <= checkNode.t) { + if (currentNodeDirection === '<' && currentNodeTime <= dateSibling.t) { error = true; } } return error; }); - return error; - } else { - if (currentNode.one('div > .badge-warning')) { - currentNode.one('div > .badge-warning').remove(); - } - return error; } + return error; }; M.availability_date.form.fillErrors = function(errors, node) { diff --git a/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-min.js b/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-min.js index 57b4ba56ae391..ea7f0646a1850 100644 --- a/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-min.js +++ b/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form-min.js @@ -1 +1 @@ -YUI.add("moodle-availability_date-form",function(o,e){M.availability_date=M.availability_date||{},M.availability_date.form=o.Object(M.core_availability.plugin),M.availability_date.form.initInner=function(e,a){this.html=e,this.defaultTime=a},M.availability_date.form.getNode=function(e){var t,i,a=''+M.util.get_string("direction_before","availability_date")+' "+this.html,n=o.Node.create(""+a+"");return e.t!==undefined?(n.setData("time",e.t),n.all("select:not([name=direction])").each(function(e){e.set("disabled",!0)}),a=M.cfg.wwwroot+"/availability/condition/date/ajax.php?action=fromtime&time="+e.t,o.io(a,{on:{success:function(e,a){var t,i,l=o.JSON.parse(a.responseText);for(t in l)(i=n.one("select[name=x\\["+t+"\\]]")).set("value",""+l[t]),i.set("disabled",!1)},failure:function(){window.alert(M.util.get_string("ajaxerror","availability_date"))}}})):n.setData("time",this.defaultTime),e.d!==undefined&&n.one("select[name=direction]").set("value",e.d),M.availability_date.form.addedEvents||(M.availability_date.form.addedEvents=!0,(a=o.one(".availability-field")).delegate("change",function(){M.core_availability.form.update()},".availability_date select[name=direction]"),a.delegate("change",function(){M.availability_date.form.updateTime(this.ancestor("span.availability_date"))},".availability_date select:not([name=direction])")),n.one("a[href=#]")&&(M.form.dateselector.init_single_date_selector(n),t=n.one("select[name=x\\[year\\]]"),i=t.set,t.set=function(e,a){i.call(t,e,a),"selectedIndex"===e&&setTimeout(function(){M.availability_date.form.updateTime(n)},0)}),n},M.availability_date.form.updateTime=function(t){var e=M.cfg.wwwroot+"/availability/condition/date/ajax.php?action=totime&year="+t.one("select[name=x\\[year\\]]").get("value")+"&month="+t.one("select[name=x\\[month\\]]").get("value")+"&day="+t.one("select[name=x\\[day\\]]").get("value")+"&hour="+t.one("select[name=x\\[hour\\]]").get("value")+"&minute="+t.one("select[name=x\\[minute\\]]").get("value");o.io(e,{on:{success:function(e,a){t.setData("time",a.responseText),M.core_availability.form.update()},failure:function(){window.alert(M.util.get_string("ajaxerror","availability_date"))}}})},M.availability_date.form.fillValue=function(e,a){e.d=a.one("select[name=direction]").get("value"),e.t=parseInt(a.getData("time"),10)},M.availability_date.form.convertTreeDateValue=function(e,a,t){var i=!1;return e.forEach(function(e){i||("date"===e.type?e.t!==parseInt(t.getData("time"),10)||t.one("select[name=direction]").get("value")!=e.d?a.push(e):i=!0:e.type===undefined&&M.availability_date.form.convertTreeDateValue(e.c,a,t))}),a},M.availability_date.form.checkConditionDate=function(e){var a,t,i,l=!1;return"&"===M.core_availability.form.rootList.getValue().op?(a=M.core_availability.form.rootList.getValue().c,a=M.availability_date.form.convertTreeDateValue(a,[],e),t=e.one("select[name=direction]").get("value"),i=parseInt(e.getData("time"),10),a.forEach(function(e){return"<"===e.d?">="===t&&i>=e.t&&(l=!0):"<"===t&&i<=e.t&&(l=!0),l})):e.one("div > .badge-warning")&&e.one("div > .badge-warning").remove(),l},M.availability_date.form.fillErrors=function(e,a){M.availability_date.form.checkConditionDate(a)&&e.push("availability_date:error_dateconflict")}},"@VERSION@",{requires:["base","node","event","io","moodle-core_availability-form"]}); \ No newline at end of file +YUI.add("moodle-availability_date-form",function(o,e){M.availability_date=M.availability_date||{},M.availability_date.form=o.Object(M.core_availability.plugin),M.availability_date.form.initInner=function(e,a){this.html=e,this.defaultTime=a},M.availability_date.form.getNode=function(e){var t,i,a=''+M.util.get_string("direction_before","availability_date")+' "+this.html,l=o.Node.create(""+a+"");return e.t!==undefined?(l.setData("time",e.t),l.all("select:not([name=direction])").each(function(e){e.set("disabled",!0)}),a=M.cfg.wwwroot+"/availability/condition/date/ajax.php?action=fromtime&time="+e.t,o.io(a,{on:{success:function(e,a){var t,i,n=o.JSON.parse(a.responseText);for(t in n)(i=l.one("select[name=x\\["+t+"\\]]")).set("value",""+n[t]),i.set("disabled",!1)},failure:function(){window.alert(M.util.get_string("ajaxerror","availability_date"))}}})):l.setData("time",this.defaultTime),e.nodeUID===undefined&&(a=new Date,e.nodeUID=a.getTime()),l.setData("nodeUID",e.nodeUID),e.d!==undefined&&l.one("select[name=direction]").set("value",e.d),M.availability_date.form.addedEvents||(M.availability_date.form.addedEvents=!0,(a=o.one(".availability-field")).delegate("change",function(){M.core_availability.form.update()},".availability_date select[name=direction]"),a.delegate("change",function(){M.availability_date.form.updateTime(this.ancestor("span.availability_date"))},".availability_date select:not([name=direction])")),l.one("a[href=#]")&&(M.form.dateselector.init_single_date_selector(l),t=l.one("select[name=x\\[year\\]]"),i=t.set,t.set=function(e,a){i.call(t,e,a),"selectedIndex"===e&&setTimeout(function(){M.availability_date.form.updateTime(l)},0)}),l},M.availability_date.form.updateTime=function(t){var e=M.cfg.wwwroot+"/availability/condition/date/ajax.php?action=totime&year="+t.one("select[name=x\\[year\\]]").get("value")+"&month="+t.one("select[name=x\\[month\\]]").get("value")+"&day="+t.one("select[name=x\\[day\\]]").get("value")+"&hour="+t.one("select[name=x\\[hour\\]]").get("value")+"&minute="+t.one("select[name=x\\[minute\\]]").get("value");o.io(e,{on:{success:function(e,a){t.setData("time",a.responseText),M.core_availability.form.update()},failure:function(){window.alert(M.util.get_string("ajaxerror","availability_date"))}}})},M.availability_date.form.fillValue=function(e,a){e.d=a.one("select[name=direction]").get("value"),e.t=parseInt(a.getData("time"),10),e.nodeUID=a.getData("nodeUID")},M.availability_date.form.findAllDateSiblings=function(e,d){var r,c=function(e){var a,t,i,n=[],l=!1,o=e.op!==undefined?e.op:null;if(e.c!==undefined){for(i=e.c,a=0;a="===i&&n>=e.t&&(a=!0):"<"===i&&n<=e.t&&(a=!0),a}),a},M.availability_date.form.fillErrors=function(e,a){M.availability_date.form.checkConditionDate(a)&&e.push("availability_date:error_dateconflict")}},"@VERSION@",{requires:["base","node","event","io","moodle-core_availability-form"]}); \ No newline at end of file diff --git a/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form.js b/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form.js index e1ab688d539a9..5db031832e21c 100644 --- a/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form.js +++ b/availability/condition/date/yui/build/moodle-availability_date-form/moodle-availability_date-form.js @@ -65,6 +65,11 @@ M.availability_date.form.getNode = function(json) { // Set default time that corresponds to the HTML selectors. node.setData('time', this.defaultTime); } + if (json.nodeUID === undefined) { + var miliTime = new Date(); + json.nodeUID = miliTime.getTime(); + } + node.setData('nodeUID', json.nodeUID); if (json.d !== undefined) { node.one('select[name=direction]').set('value', json.d); } @@ -114,7 +119,7 @@ M.availability_date.form.getNode = function(json) { * gets an AJAX response. * * @method updateTime - * @param {Y.Node} component Node for plugin controls + * @param {Y.Node} node Node for plugin controls */ M.availability_date.form.updateTime = function(node) { // After a change to the date/time we need to recompute the @@ -140,39 +145,53 @@ M.availability_date.form.updateTime = function(node) { M.availability_date.form.fillValue = function(value, node) { value.d = node.one('select[name=direction]').get('value'); value.t = parseInt(node.getData('time'), 10); + value.nodeUID = node.getData('nodeUID'); }; /** - * List out Date node value in an array node. - * - * This will go through all array node and list from earlier date node to current date node. + * List out Date node value in the same branch. * - * @method convertTreeDateValue - * @param {array} tree Tree node to convert - * @param {array} arrayDateNode - * @param {array} currentNode current node. + * This will go through all array node and list nodes that are sibling of the current node. * - * @return {array} arrayDateNode + * @method findAllDateSiblings + * @param {Array} tree Tree items to convert + * @param {Number} nodeUIDToFind node UID to find. + * @return {Array|null} array of surrounding date avaiability values */ -M.availability_date.form.convertTreeDateValue = function(tree, arrayDateNode, currentNode) { - var shouldSkip = false; - tree.forEach(function(node) { - if (shouldSkip) { - return; - } - if (node.type === 'date') { - // We go through all tree node, if we meet the current node then return. - if (node.t === parseInt(currentNode.getData('time'), 10) - && currentNode.one('select[name=direction]').get('value') == node.d) { - shouldSkip = true; - return; +M.availability_date.form.findAllDateSiblings = function(tree, nodeUIDToFind) { + var itemValue = null; + var siblingsFinderRecursive = function(itemsTree) { + var dateSiblings = []; + var nodeFound = false; + var index; + var childDates; + var currentOp = itemsTree.op !== undefined ? itemsTree.op : null; + if (itemsTree.c !== undefined) { + var children = itemsTree.c; + for (index = 0; index < children.length; index++) { + itemValue = children.at(index); + if (itemValue.type === undefined) { + childDates = siblingsFinderRecursive(itemValue); + if (childDates) { + return childDates; + } + } + if (itemValue.type === 'date') { + // We go through all tree node, if we meet the current node then we add all nodes in the current branch. + if (nodeUIDToFind === itemValue.nodeUID) { + nodeFound = true; + } else if (currentOp === '&') { + dateSiblings.push(itemValue); + } + } + } + if (nodeFound) { + return dateSiblings; } - arrayDateNode.push(node); - } else if (node.type === undefined) { - M.availability_date.form.convertTreeDateValue(node.c, arrayDateNode, currentNode); } - }); - return arrayDateNode; + return null; + }; + return siblingsFinderRecursive(tree); }; /** @@ -181,37 +200,34 @@ M.availability_date.form.convertTreeDateValue = function(tree, arrayDateNode, cu * This will check current date node with all date node in tree node. * * @method checkConditionDate - * @param {array} currentNode The curent node. + * @param {Y.Node} currentNode The curent node. * * @return {boolean} error Return true if the date is conflict. */ M.availability_date.form.checkConditionDate = function(currentNode) { var error = false; - if (M.core_availability.form.rootList.getValue().op === '&') { - var jsValue = M.core_availability.form.rootList.getValue().c; - var arrayDateNode = M.availability_date.form.convertTreeDateValue(jsValue, [], currentNode); - var currentNodeDirection = currentNode.one('select[name=direction]').get('value'); - var currentNodeTime = parseInt(currentNode.getData('time'), 10); - arrayDateNode.forEach(function(checkNode) { + var currentNodeUID = currentNode.getData('nodeUID'); + var currentNodeDirection = currentNode.one('select[name=direction]').get('value'); + var currentNodeTime = parseInt(currentNode.getData('time'), 10); + var dateSiblings = M.availability_date.form.findAllDateSiblings( + M.core_availability.form.rootList.getValue(), + currentNodeUID); + if (dateSiblings) { + dateSiblings.forEach(function(dateSibling) { // Validate if the date is conflict. - if (checkNode.d === '<') { - if (currentNodeDirection === '>=' && currentNodeTime >= checkNode.t) { + if (dateSibling.d === '<') { + if (currentNodeDirection === '>=' && currentNodeTime >= dateSibling.t) { error = true; } } else { - if (currentNodeDirection === '<' && currentNodeTime <= checkNode.t) { + if (currentNodeDirection === '<' && currentNodeTime <= dateSibling.t) { error = true; } } return error; }); - return error; - } else { - if (currentNode.one('div > .badge-warning')) { - currentNode.one('div > .badge-warning').remove(); - } - return error; } + return error; }; M.availability_date.form.fillErrors = function(errors, node) { diff --git a/availability/condition/date/yui/src/form/js/form.js b/availability/condition/date/yui/src/form/js/form.js index ca0ca736d4440..b2d1866e19d15 100644 --- a/availability/condition/date/yui/src/form/js/form.js +++ b/availability/condition/date/yui/src/form/js/form.js @@ -63,6 +63,11 @@ M.availability_date.form.getNode = function(json) { // Set default time that corresponds to the HTML selectors. node.setData('time', this.defaultTime); } + if (json.nodeUID === undefined) { + var miliTime = new Date(); + json.nodeUID = miliTime.getTime(); + } + node.setData('nodeUID', json.nodeUID); if (json.d !== undefined) { node.one('select[name=direction]').set('value', json.d); } @@ -112,7 +117,7 @@ M.availability_date.form.getNode = function(json) { * gets an AJAX response. * * @method updateTime - * @param {Y.Node} component Node for plugin controls + * @param {Y.Node} node Node for plugin controls */ M.availability_date.form.updateTime = function(node) { // After a change to the date/time we need to recompute the @@ -138,39 +143,53 @@ M.availability_date.form.updateTime = function(node) { M.availability_date.form.fillValue = function(value, node) { value.d = node.one('select[name=direction]').get('value'); value.t = parseInt(node.getData('time'), 10); + value.nodeUID = node.getData('nodeUID'); }; /** - * List out Date node value in an array node. - * - * This will go through all array node and list from earlier date node to current date node. + * List out Date node value in the same branch. * - * @method convertTreeDateValue - * @param {array} tree Tree node to convert - * @param {array} arrayDateNode - * @param {array} currentNode current node. + * This will go through all array node and list nodes that are sibling of the current node. * - * @return {array} arrayDateNode + * @method findAllDateSiblings + * @param {Array} tree Tree items to convert + * @param {Number} nodeUIDToFind node UID to find. + * @return {Array|null} array of surrounding date avaiability values */ -M.availability_date.form.convertTreeDateValue = function(tree, arrayDateNode, currentNode) { - var shouldSkip = false; - tree.forEach(function(node) { - if (shouldSkip) { - return; - } - if (node.type === 'date') { - // We go through all tree node, if we meet the current node then return. - if (node.t === parseInt(currentNode.getData('time'), 10) - && currentNode.one('select[name=direction]').get('value') == node.d) { - shouldSkip = true; - return; +M.availability_date.form.findAllDateSiblings = function(tree, nodeUIDToFind) { + var itemValue = null; + var siblingsFinderRecursive = function(itemsTree) { + var dateSiblings = []; + var nodeFound = false; + var index; + var childDates; + var currentOp = itemsTree.op !== undefined ? itemsTree.op : null; + if (itemsTree.c !== undefined) { + var children = itemsTree.c; + for (index = 0; index < children.length; index++) { + itemValue = children.at(index); + if (itemValue.type === undefined) { + childDates = siblingsFinderRecursive(itemValue); + if (childDates) { + return childDates; + } + } + if (itemValue.type === 'date') { + // We go through all tree node, if we meet the current node then we add all nodes in the current branch. + if (nodeUIDToFind === itemValue.nodeUID) { + nodeFound = true; + } else if (currentOp === '&') { + dateSiblings.push(itemValue); + } + } + } + if (nodeFound) { + return dateSiblings; } - arrayDateNode.push(node); - } else if (node.type === undefined) { - M.availability_date.form.convertTreeDateValue(node.c, arrayDateNode, currentNode); } - }); - return arrayDateNode; + return null; + }; + return siblingsFinderRecursive(tree); }; /** @@ -179,37 +198,34 @@ M.availability_date.form.convertTreeDateValue = function(tree, arrayDateNode, cu * This will check current date node with all date node in tree node. * * @method checkConditionDate - * @param {array} currentNode The curent node. + * @param {Y.Node} currentNode The curent node. * * @return {boolean} error Return true if the date is conflict. */ M.availability_date.form.checkConditionDate = function(currentNode) { var error = false; - if (M.core_availability.form.rootList.getValue().op === '&') { - var jsValue = M.core_availability.form.rootList.getValue().c; - var arrayDateNode = M.availability_date.form.convertTreeDateValue(jsValue, [], currentNode); - var currentNodeDirection = currentNode.one('select[name=direction]').get('value'); - var currentNodeTime = parseInt(currentNode.getData('time'), 10); - arrayDateNode.forEach(function(checkNode) { + var currentNodeUID = currentNode.getData('nodeUID'); + var currentNodeDirection = currentNode.one('select[name=direction]').get('value'); + var currentNodeTime = parseInt(currentNode.getData('time'), 10); + var dateSiblings = M.availability_date.form.findAllDateSiblings( + M.core_availability.form.rootList.getValue(), + currentNodeUID); + if (dateSiblings) { + dateSiblings.forEach(function(dateSibling) { // Validate if the date is conflict. - if (checkNode.d === '<') { - if (currentNodeDirection === '>=' && currentNodeTime >= checkNode.t) { + if (dateSibling.d === '<') { + if (currentNodeDirection === '>=' && currentNodeTime >= dateSibling.t) { error = true; } } else { - if (currentNodeDirection === '<' && currentNodeTime <= checkNode.t) { + if (currentNodeDirection === '<' && currentNodeTime <= dateSibling.t) { error = true; } } return error; }); - return error; - } else { - if (currentNode.one('div > .badge-warning')) { - currentNode.one('div > .badge-warning').remove(); - } - return error; } + return error; }; M.availability_date.form.fillErrors = function(errors, node) { diff --git a/availability/tests/behat/behat_availability.php b/availability/tests/behat/behat_availability.php new file mode 100644 index 0000000000000..4d89157d2c1de --- /dev/null +++ b/availability/tests/behat/behat_availability.php @@ -0,0 +1,67 @@ +. + +require_once(__DIR__ . '/../../../lib/behat/behat_base.php'); + +/** + * Availability related behat steps and selectors definitions. + * + * @package core_availability + * @category test + * @copyright 2023 Amaia Anabitarte + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_availability extends behat_base { + + /** + * Return the list of partial named selectors. + * + * @return array + */ + public static function get_partial_named_selectors(): array { + return [ + new behat_component_named_selector( + 'Activity availability', [ + ".//li[contains(concat(' ', normalize-space(@class), ' '), ' activity ')]" + . "[descendant::*[contains(normalize-space(.), %locator%)]]//div[@data-region='availabilityinfo']", + ] + ), + new behat_component_named_selector( + 'Section availability', [".//li[@id = %locator%]//div[@data-region='availabilityinfo']"], + ), + new behat_component_named_selector( + 'Set Of Restrictions', ["//div[h3[@data-restriction-order=%locator% and contains(text(), 'Set of')]]"], + ), + ]; + } + + /** + * Return the list of exact named selectors + * + * @return array + */ + public static function get_exact_named_selectors(): array { + return [ + new behat_component_named_selector( + 'Availability Button Area', + [ + "//h3[@data-restriction-order=%locator%]/following-sibling::div[contains(@class,'availability-inner')]/" + . "div[contains(@class,'availability-button')]", + ], + ), + ]; + } +} diff --git a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js index 34c0df0af14a8..8477127903fd5 100644 --- a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js +++ b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-debug.js @@ -560,7 +560,7 @@ M.core_availability.List.prototype.renumber = function(parentNumber) { } var heading = M.util.get_string('setheading', 'availability', headingParams); this.node.one('> h3').set('innerHTML', heading); - + this.node.one('> h3').getDOMNode().dataset.restrictionOrder = parentNumber ? parentNumber : 'root'; // Do children. for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; @@ -1008,6 +1008,7 @@ M.core_availability.Item.prototype.renumber = function(number) { headingParams.number = number + ':'; var heading = M.util.get_string('itemheading', 'availability', headingParams); this.node.one('> h3').set('innerHTML', heading); + this.node.one('> h3').getDOMNode().dataset.restrictionOrder = number ? number : 'root'; }; /** diff --git a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js index 7d34137541246..4f1927b6c2674 100644 --- a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js +++ b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form-min.js @@ -1,3 +1,3 @@ YUI.add("moodle-core_availability-form",function(d,i){M.core_availability=M.core_availability||{},M.core_availability.form={plugins:{},field:null,mainDiv:null,rootList:null,idCounter:0,restrictByGroup:null,init:function(i){var t,e,a,l,n;for(t in i)e=i[t],(a=M[e[0]].form).init.apply(a,e);if(this.field=d.one("#id_availabilityconditionsjson"),this.field.setAttribute("aria-hidden","true"),this.mainDiv=d.Node.create(''),this.field.insert(this.mainDiv,"after"),n=null,""!==(l=this.field.get("value")))try{n=d.JSON.parse(l)}catch(o){this.field.set("value","")}this.rootList=new M.core_availability.List(n,!0),this.mainDiv.appendChild(this.rootList.node),this.update(),this.rootList.renumber(),this.mainDiv.setAttribute("aria-live","polite"),this.field.ancestor("form").on("submit",function(){this.mainDiv.all("input,textarea,select").set("disabled",!0)},this),this.restrictByGroup=d.one("#restrictbygroup"),this.restrictByGroup&&(this.restrictByGroup.on("click",this.addRestrictByGroup,this),l=d.one("#id_groupmode"),n=d.one("#id_groupingid"),l&&l.on("change",this.updateRestrictByGroup,this),n&&n.on("change",this.updateRestrictByGroup,this),this.updateRestrictByGroup())},update:function(){var i=this.rootList.getValue(),t=[];this.rootList.fillErrors(t),0!==t.length&&(i.errors=t),this.field.set("value",d.JSON.stringify(i)),this.updateRestrictByGroup()},updateRestrictByGroup:function(){var i,t,e,a;this.restrictByGroup&&("&"!==this.rootList.getValue().op||(this.rootList.hasItemOfType("group")||this.rootList.hasItemOfType("grouping"))?this.restrictByGroup.set("disabled",!0):(i=d.one("#id_groupmode"),t=d.one("#id_groupingid"),e=1===Number(this.restrictByGroup.getData("groupavailability")),a=1===Number(this.restrictByGroup.getData("groupingavailability")),i&&0!==Number(i.get("value"))&&e||t&&0!==Number(t.get("value"))&&a?this.restrictByGroup.set("disabled",!1):this.restrictByGroup.set("disabled",!0)))},addRestrictByGroup:function(i){var t,e,a,l;i.preventDefault(),i=d.one("#id_groupmode"),t=d.one("#id_groupingid"),e=1===Number(this.restrictByGroup.getData("groupavailability")),a=1===Number(this.restrictByGroup.getData("groupingavailability")),t&&0!==Number(t.get("value"))&&a?l=new M.core_availability.Item({type:"grouping",id:Number(t.get("value"))},!0):i&&e&&(l=new M.core_availability.Item({type:"group"},!0)),null!==l&&(this.rootList.addChild(l),this.update(),this.rootList.renumber(),this.rootList.updateHtml())}},M.core_availability.plugin={allowAdd:!1,init:function(i,t,e){i=i.replace(/^availability_/,"");this.allowAdd=t,(M.core_availability.form.plugins[i]=this).initInner.apply(this,e)},initInner:function(){},getNode:function(){throw"getNode not implemented"},fillValue:function(){throw"fillValue not implemented"},fillErrors:function(){},focusAfterAdd:function(i){i.one("input:not([disabled]),select:not([disabled])").focus()}},M.core_availability.List=function(i,t,e){var a,l,n;if(this.children=[],t!==undefined&&(this.root=t),this.node=d.Node.create('

'+M.util.get_string("listheader_sign_before","availability")+' '+M.util.get_string("listheader_single","availability")+''+M.util.get_string("listheader_multi_before","availability")+' "+M.util.get_string("listheader_multi_after","availability")+'
'+M.util.get_string("none","moodle")+'
'),t||this.node.addClass("availability-childlist d-sm-flex align-items-center"),this.inner=this.node.one("> .availability-inner"),a=!0,t?(i&&i.show!==undefined&&(a=i.show),this.eyeIcon=new M.core_availability.EyeIcon(!1,a),this.node.one(".availability-header").get("firstChild").insert(this.eyeIcon.span,"before")):e&&(i&&i.showc!==undefined&&(a=i.showc),this.eyeIcon=new M.core_availability.EyeIcon(!1,a),this.inner.insert(this.eyeIcon.span,"before")),t||(e=new M.core_availability.DeleteIcon(this),(a=this.node.one(".availability-none")).appendChild(document.createTextNode(" ")),a.appendChild(e.span),a.appendChild(d.Node.create(''+M.util.get_string("invalid","availability")+""))),(t=d.Node.create('")).on("click",function(){this.clickAdd()},this),this.node.one("div.availability-button").appendChild(t),i){switch(i.op){case"&":case"|":this.node.one(".availability-neg").set("value","");break;case"!&":case"!|":this.node.one(".availability-neg").set("value","!")}switch(i.op){case"&":case"!&":this.node.one(".availability-op").set("value","&");break;case"|":case"!|":this.node.one(".availability-op").set("value","|")}for(l=0;l')),this.children.push(i),this.inner.one(".availability-children").appendChild(i.node)},M.core_availability.List.prototype.focusAfterAdd=function(){this.inner.one("button").focus()},M.core_availability.List.prototype.isIndividualShowIcons=function(){var i,t;if(!this.root)throw"Can only call this on root list";return i="!"===this.node.one(".availability-neg").get("value"),t="|"===this.node.one(".availability-op").get("value"),!i&&!t||i&&t},M.core_availability.List.prototype.renumber=function(i){var t,e={count:this.children.length},a=i===undefined?e.number="":(e.number=i+":",i+"."),i=M.util.get_string("setheading","availability",e);for(this.node.one("> h3").set("innerHTML",i),t=0;t .availability-children").removeAttribute("aria-hidden"),this.inner.one("> .availability-none").setAttribute("aria-hidden","true"),this.inner.one("> .availability-header").removeAttribute("aria-hidden"),1 .availability-children").setAttribute("aria-hidden","true"),this.inner.one("> .availability-none").removeAttribute("aria-hidden"),this.inner.one("> .availability-header").setAttribute("aria-hidden","true")),this.root){for(i=this.isIndividualShowIcons(),t=0;t .availability-children > .availability-connector span.label").each(function(i){i.set("innerHTML",a)})},M.core_availability.List.prototype.deleteDescendant=function(i){for(var t,e,a=0;a .availability-children").removeChild(e),M.core_availability.form.update(),this.updateHtml(),this.inner.one("> .availability-button").one("button").focus(),!0;if(t instanceof M.core_availability.List&&t.deleteDescendant(i))return!0}return!1},M.core_availability.List.prototype.clickAdd=function(){var i,t,e,a,l,n=d.Node.create('
    "),o=n.one("button"),s={dialog:null},r=n.one("ul");for(l in M.core_availability.form.plugins)M.core_availability.form.plugins[l].allowAdd&&(i=d.Node.create('
  • '),(e=d.Node.create('
    ")).on("click",this.getAddHandler(l,s),this),i.appendChild(e),a=d.Node.create('
    "),i.appendChild(a),r.appendChild(i));i=d.Node.create('
  • '),(e=d.Node.create('
    ")).on("click",this.getAddHandler(null,s),this),i.appendChild(e),a=d.Node.create('
    "),i.appendChild(a),r.appendChild(i),n={headerContent:M.util.get_string("addrestriction","availability"),bodyContent:n,additionalBaseClass:"availability-dialogue",draggable:!0,modal:!0,closeButton:!1,width:"450px"},s.dialog=new M.core.dialogue(n),s.dialog.show(),o.on("click",function(){s.dialog.destroy(),this.inner.one("> .availability-button").one("button").focus()},this)},M.core_availability.List.prototype.getAddHandler=function(t,e){return function(){var i=t?new M.core_availability.Item({type:t,creating:!0},this.root):new M.core_availability.List({c:[],showc:!0},!1,this.root);this.addChild(i),M.core_availability.form.update(),M.core_availability.form.rootList.renumber(),this.updateHtml(),e.dialog.destroy(),i.focusAfterAdd()}},M.core_availability.List.prototype.getValue=function(){var i,t={};for(t.op=this.node.one(".availability-neg").get("value")+this.node.one(".availability-op").get("value"),t.c=[],i=0;i'+M.util.get_string("missingplugin","availability")+"")):(this.plugin=M.core_availability.form.plugins[i.type],this.pluginNode=this.plugin.getNode(i),this.pluginNode.addClass("availability_"+i.type)),this.node=d.Node.create('

    '),t&&(t=!0,i.showc!==undefined&&(t=i.showc),this.eyeIcon=new M.core_availability.EyeIcon(!0,t),this.node.appendChild(this.eyeIcon.span)),this.pluginNode.addClass("availability-plugincontrols"),this.node.appendChild(this.pluginNode),i=new M.core_availability.DeleteIcon(this),this.node.appendChild(i.span),this.node.appendChild(document.createTextNode(" ")),this.node.appendChild(d.Node.create(''))},M.core_availability.Item.prototype.getValue=function(){var i={type:this.pluginType};return this.plugin&&this.plugin.fillValue(i,this.pluginNode),i},M.core_availability.Item.prototype.fillErrors=function(i){var t,e,a,l=i.length;this.plugin?this.plugin.fillErrors(i,this.pluginNode):i.push("core_availability:item_unknowntype"),t=this.node.one("> .badge-warning"),i.length===l||t.get("firstChild")?i.length===l&&t.get("firstChild")&&t.get("firstChild").remove():(l="",e=(i=i[i.length-1].split(":"))[0],a="[["+(i=i[1])+","+e+"]]",(l=M.util.get_string(i,e))===a&&(l=M.util.get_string("invalid","availability")),t.appendChild(document.createTextNode(l)))},M.core_availability.Item.prototype.renumber=function(i){var t={number:i};this.plugin?t.type=M.util.get_string("title","availability_"+this.pluginType):t.type="["+this.pluginType+"]",t.number=i+":",i=M.util.get_string("itemheading","availability",t),this.node.one("> h3").set("innerHTML",i)},M.core_availability.Item.prototype.focusAfterAdd=function(){this.plugin.focusAfterAdd(this.pluginNode)},M.core_availability.Item.prototype.pluginType=null,M.core_availability.Item.prototype.plugin=null,M.core_availability.Item.prototype.eyeIcon=null,M.core_availability.Item.prototype.node=null,M.core_availability.Item.prototype.pluginNode=null,M.core_availability.EyeIcon=function(i,t){var e,a,l,n;this.individual=i,this.span=d.Node.create(''),e=d.Node.create(""),this.span.appendChild(e),a=i?"_individual":"_all",l=function(){var i=M.util.get_string("hidden"+a,"availability");e.set("src",M.util.image_url("i/show","core")),e.set("alt",i),this.span.set("title",i+" • "+M.util.get_string("show_verb","availability"))},n=function(){var i=M.util.get_string("shown"+a,"availability");e.set("src",M.util.image_url("i/hide","core")),e.set("alt",i),this.span.set("title",i+" • "+M.util.get_string("hide_verb","availability"))},(t?n:l).call(this),this.span.on("click",i=function(i){i.preventDefault(),(this.isHidden()?n:l).call(this),M.core_availability.form.update()},this),this.span.on("key",i,"up:32",this),this.span.on("key",function(i){i.preventDefault()},"down:32",this)},M.core_availability.EyeIcon.prototype.individual=!1,M.core_availability.EyeIcon.prototype.span=null,M.core_availability.EyeIcon.prototype.isHidden=function(){var i=this.individual?"_individual":"_all",i=M.util.get_string("hidden"+i,"availability");return this.span.one("img").get("alt")===i},M.core_availability.DeleteIcon=function(t){var i;this.span=d.Node.create(''),i=d.Node.create(''+M.util.get_string('),this.span.appendChild(i),this.span.on("click",i=function(i){i.preventDefault(),M.core_availability.form.rootList.deleteDescendant(t),M.core_availability.form.rootList.renumber()},this),this.span.on("key",i,"up:32",this),this.span.on("key",function(i){i.preventDefault()},"down:32",this)},M.core_availability.DeleteIcon.prototype.span=null},"@VERSION@",{requires:["base","node","event","event-delegate","panel","moodle-core-notification-dialogue","json"]}); \ No newline at end of file +):new M.core_availability.List(n,!1,this.root),this.addChild(n)}this.node.one(".availability-neg").on("change",function(){M.core_availability.form.update(),this.updateHtml()},this),this.node.one(".availability-op").on("change",function(){M.core_availability.form.update(),this.updateHtml()},this),this.updateHtml()},M.core_availability.List.prototype.addChild=function(i){0')),this.children.push(i),this.inner.one(".availability-children").appendChild(i.node)},M.core_availability.List.prototype.focusAfterAdd=function(){this.inner.one("button").focus()},M.core_availability.List.prototype.isIndividualShowIcons=function(){var i,t;if(!this.root)throw"Can only call this on root list";return i="!"===this.node.one(".availability-neg").get("value"),t="|"===this.node.one(".availability-op").get("value"),!i&&!t||i&&t},M.core_availability.List.prototype.renumber=function(i){var t,e={count:this.children.length},a=i===undefined?e.number="":(e.number=i+":",i+"."),e=M.util.get_string("setheading","availability",e);for(this.node.one("> h3").set("innerHTML",e),this.node.one("> h3").getDOMNode().dataset.restrictionOrder=i||"root",t=0;t .availability-children").removeAttribute("aria-hidden"),this.inner.one("> .availability-none").setAttribute("aria-hidden","true"),this.inner.one("> .availability-header").removeAttribute("aria-hidden"),1 .availability-children").setAttribute("aria-hidden","true"),this.inner.one("> .availability-none").removeAttribute("aria-hidden"),this.inner.one("> .availability-header").setAttribute("aria-hidden","true")),this.root){for(i=this.isIndividualShowIcons(),t=0;t .availability-children > .availability-connector span.label").each(function(i){i.set("innerHTML",a)})},M.core_availability.List.prototype.deleteDescendant=function(i){for(var t,e,a=0;a .availability-children").removeChild(e),M.core_availability.form.update(),this.updateHtml(),this.inner.one("> .availability-button").one("button").focus(),!0;if(t instanceof M.core_availability.List&&t.deleteDescendant(i))return!0}return!1},M.core_availability.List.prototype.clickAdd=function(){var i,t,e,a,l,n=d.Node.create('
      "),o=n.one("button"),s={dialog:null},r=n.one("ul");for(l in M.core_availability.form.plugins)M.core_availability.form.plugins[l].allowAdd&&(i=d.Node.create('
    • '),(e=d.Node.create('
      ")).on("click",this.getAddHandler(l,s),this),i.appendChild(e),a=d.Node.create('
      "),i.appendChild(a),r.appendChild(i));i=d.Node.create('
    • '),(e=d.Node.create('
      ")).on("click",this.getAddHandler(null,s),this),i.appendChild(e),a=d.Node.create('
      "),i.appendChild(a),r.appendChild(i),n={headerContent:M.util.get_string("addrestriction","availability"),bodyContent:n,additionalBaseClass:"availability-dialogue",draggable:!0,modal:!0,closeButton:!1,width:"450px"},s.dialog=new M.core.dialogue(n),s.dialog.show(),o.on("click",function(){s.dialog.destroy(),this.inner.one("> .availability-button").one("button").focus()},this)},M.core_availability.List.prototype.getAddHandler=function(t,e){return function(){var i=t?new M.core_availability.Item({type:t,creating:!0},this.root):new M.core_availability.List({c:[],showc:!0},!1,this.root);this.addChild(i),M.core_availability.form.update(),M.core_availability.form.rootList.renumber(),this.updateHtml(),e.dialog.destroy(),i.focusAfterAdd()}},M.core_availability.List.prototype.getValue=function(){var i,t={};for(t.op=this.node.one(".availability-neg").get("value")+this.node.one(".availability-op").get("value"),t.c=[],i=0;i'+M.util.get_string("missingplugin","availability")+"")):(this.plugin=M.core_availability.form.plugins[i.type],this.pluginNode=this.plugin.getNode(i),this.pluginNode.addClass("availability_"+i.type)),this.node=d.Node.create('

      '),t&&(t=!0,i.showc!==undefined&&(t=i.showc),this.eyeIcon=new M.core_availability.EyeIcon(!0,t),this.node.appendChild(this.eyeIcon.span)),this.pluginNode.addClass("availability-plugincontrols"),this.node.appendChild(this.pluginNode),i=new M.core_availability.DeleteIcon(this),this.node.appendChild(i.span),this.node.appendChild(document.createTextNode(" ")),this.node.appendChild(d.Node.create(''))},M.core_availability.Item.prototype.getValue=function(){var i={type:this.pluginType};return this.plugin&&this.plugin.fillValue(i,this.pluginNode),i},M.core_availability.Item.prototype.fillErrors=function(i){var t,e,a,l=i.length;this.plugin?this.plugin.fillErrors(i,this.pluginNode):i.push("core_availability:item_unknowntype"),t=this.node.one("> .badge-warning"),i.length===l||t.get("firstChild")?i.length===l&&t.get("firstChild")&&t.get("firstChild").remove():(l="",e=(i=i[i.length-1].split(":"))[0],a="[["+(i=i[1])+","+e+"]]",(l=M.util.get_string(i,e))===a&&(l=M.util.get_string("invalid","availability")),t.appendChild(document.createTextNode(l)))},M.core_availability.Item.prototype.renumber=function(i){var t={number:i};this.plugin?t.type=M.util.get_string("title","availability_"+this.pluginType):t.type="["+this.pluginType+"]",t.number=i+":",t=M.util.get_string("itemheading","availability",t),this.node.one("> h3").set("innerHTML",t),this.node.one("> h3").getDOMNode().dataset.restrictionOrder=i||"root"},M.core_availability.Item.prototype.focusAfterAdd=function(){this.plugin.focusAfterAdd(this.pluginNode)},M.core_availability.Item.prototype.pluginType=null,M.core_availability.Item.prototype.plugin=null,M.core_availability.Item.prototype.eyeIcon=null,M.core_availability.Item.prototype.node=null,M.core_availability.Item.prototype.pluginNode=null,M.core_availability.EyeIcon=function(i,t){var e,a,l,n;this.individual=i,this.span=d.Node.create('
      '),e=d.Node.create(""),this.span.appendChild(e),a=i?"_individual":"_all",l=function(){var i=M.util.get_string("hidden"+a,"availability");e.set("src",M.util.image_url("i/show","core")),e.set("alt",i),this.span.set("title",i+" • "+M.util.get_string("show_verb","availability"))},n=function(){var i=M.util.get_string("shown"+a,"availability");e.set("src",M.util.image_url("i/hide","core")),e.set("alt",i),this.span.set("title",i+" • "+M.util.get_string("hide_verb","availability"))},(t?n:l).call(this),this.span.on("click",i=function(i){i.preventDefault(),(this.isHidden()?n:l).call(this),M.core_availability.form.update()},this),this.span.on("key",i,"up:32",this),this.span.on("key",function(i){i.preventDefault()},"down:32",this)},M.core_availability.EyeIcon.prototype.individual=!1,M.core_availability.EyeIcon.prototype.span=null,M.core_availability.EyeIcon.prototype.isHidden=function(){var i=this.individual?"_individual":"_all",i=M.util.get_string("hidden"+i,"availability");return this.span.one("img").get("alt")===i},M.core_availability.DeleteIcon=function(t){var i;this.span=d.Node.create(''),i=d.Node.create(''+M.util.get_string('),this.span.appendChild(i),this.span.on("click",i=function(i){i.preventDefault(),M.core_availability.form.rootList.deleteDescendant(t),M.core_availability.form.rootList.renumber()},this),this.span.on("key",i,"up:32",this),this.span.on("key",function(i){i.preventDefault()},"down:32",this)},M.core_availability.DeleteIcon.prototype.span=null},"@VERSION@",{requires:["base","node","event","event-delegate","panel","moodle-core-notification-dialogue","json"]}); \ No newline at end of file diff --git a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js index 34c0df0af14a8..8477127903fd5 100644 --- a/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js +++ b/availability/yui/build/moodle-core_availability-form/moodle-core_availability-form.js @@ -560,7 +560,7 @@ M.core_availability.List.prototype.renumber = function(parentNumber) { } var heading = M.util.get_string('setheading', 'availability', headingParams); this.node.one('> h3').set('innerHTML', heading); - + this.node.one('> h3').getDOMNode().dataset.restrictionOrder = parentNumber ? parentNumber : 'root'; // Do children. for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; @@ -1008,6 +1008,7 @@ M.core_availability.Item.prototype.renumber = function(number) { headingParams.number = number + ':'; var heading = M.util.get_string('itemheading', 'availability', headingParams); this.node.one('> h3').set('innerHTML', heading); + this.node.one('> h3').getDOMNode().dataset.restrictionOrder = number ? number : 'root'; }; /** diff --git a/availability/yui/src/form/js/form.js b/availability/yui/src/form/js/form.js index 5e5e739959854..7e80eee41b6e4 100644 --- a/availability/yui/src/form/js/form.js +++ b/availability/yui/src/form/js/form.js @@ -558,7 +558,7 @@ M.core_availability.List.prototype.renumber = function(parentNumber) { } var heading = M.util.get_string('setheading', 'availability', headingParams); this.node.one('> h3').set('innerHTML', heading); - + this.node.one('> h3').getDOMNode().dataset.restrictionOrder = parentNumber ? parentNumber : 'root'; // Do children. for (var i = 0; i < this.children.length; i++) { var child = this.children[i]; @@ -1006,6 +1006,7 @@ M.core_availability.Item.prototype.renumber = function(number) { headingParams.number = number + ':'; var heading = M.util.get_string('itemheading', 'availability', headingParams); this.node.one('> h3').set('innerHTML', heading); + this.node.one('> h3').getDOMNode().dataset.restrictionOrder = number ? number : 'root'; }; /** diff --git a/backup/converter/moodle1/handlerlib.php b/backup/converter/moodle1/handlerlib.php index bbdfd7f97fbce..a32e436582906 100644 --- a/backup/converter/moodle1/handlerlib.php +++ b/backup/converter/moodle1/handlerlib.php @@ -867,7 +867,9 @@ public function process_course_module($data, $raw) { $plugin->version = null; $module = $plugin; include($versionfile); - $data['version'] = $plugin->version; + // Have to hardcode - since quiz uses some hardcoded version numbers when restoring. + // This is the lowest number used minus one. + $data['version'] = 2011010099; } else { $data['version'] = null; } diff --git a/blocks/completionstatus/tests/behat/block_completionstatus_activity_completion.feature b/blocks/completionstatus/tests/behat/block_completionstatus_activity_completion.feature index 32f1ca0683727..941a4683529e2 100644 --- a/blocks/completionstatus/tests/behat/block_completionstatus_activity_completion.feature +++ b/blocks/completionstatus/tests/behat/block_completionstatus_activity_completion.feature @@ -1,4 +1,4 @@ -@block @block_completionstatus +@block @block_completionstatus @core_completion Feature: Enable Block Completion in a course using activity completion In order to view the completion block in a course As a teacher @@ -17,117 +17,69 @@ Feature: Enable Block Completion in a course using activity completion | teacher1 | C1 | editingteacher | | student1 | C1 | student | And the following "activities" exist: - | activity | course | idnumber | name | intro | - | page | C1 | page1 | Test page name | Test page description | - | assign | C1 | assign1 | Test assign name | Test page description | + | activity | course | idnumber | name | gradepass | completion | completionview | completionusegrade | completionpassgrade | + | page | C1 | page1 | Test page name | | 2 | 1 | 0 | 0 | + | assign | C1 | assign1 | Test assign name | 50 | 2 | 0 | 1 | 1 | + And the following "blocks" exist: + | blockname | contextlevel | reference | pagetypepattern | defaultregion | + | completionstatus | Course | C1 | course-view-* | side-pre | - Scenario: Add the block to a the course and add course completion items - Given I log in as "teacher1" - And I am on "Course 1" course homepage with editing mode on - And I follow "Test page name" - And I navigate to "Settings" in current page administration - And I set the following fields to these values: - | Completion tracking | Show activity as complete when conditions are met | - | Require view | 1 | - And I press "Save and return to course" - And I add the "Course completion status" block + Scenario: Completion status block when student has not started any activities + Given I am on the "Course 1" course page logged in as teacher1 And I navigate to "Course completion" in current page administration And I expand all fieldsets And I set the following fields to these values: | Test page name | 1 | And I press "Save changes" - And I log out - When I log in as "student1" - And I am on "Course 1" course homepage + When I am on the "Course 1" course page logged in as student1 Then I should see "Status: Not yet started" in the "Course completion status" "block" And I should see "0 of 1" in the "Activity completion" "table_row" - Scenario: Add the block to a the course and add course completion items - Given I log in as "teacher1" - And I am on "Course 1" course homepage with editing mode on - And I follow "Test page name" - And I navigate to "Settings" in current page administration - And I set the following fields to these values: - | Completion tracking | Show activity as complete when conditions are met | - | Require view | 1 | - And I press "Save and return to course" - And I add the "Course completion status" block + Scenario: Completion status block when student has completed a page + Given I am on the "Course 1" course page logged in as teacher1 And I navigate to "Course completion" in current page administration And I expand all fieldsets And I set the following fields to these values: | Test page name | 1 | And I press "Save changes" - And I log out - When I log in as "student1" - And I am on "Course 1" course homepage - And I follow "Test page name" + When I am on the "Test page name" "page activity" page logged in as student1 And I am on "Course 1" course homepage Then I should see "Status: Complete" in the "Course completion status" "block" And I should see "1 of 1" in the "Activity completion" "table_row" And I follow "More details" And I should see "Yes" in the "Activity completion" "table_row" - @javascript - Scenario: Add the block to a the course and add course completion items with passing grade - Given I am on the "Test assign name" "assign activity" page logged in as teacher1 - And I navigate to "Settings" in current page administration - And I set the following fields to these values: - | Completion tracking | Show activity as complete when conditions are met | - | completionusegrade | 1 | - | completionpassgrade | 1 | - | gradepass | 50 | - And I press "Save and return to course" - And I am on the "Test assign name" "assign activity" page - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student" "table_row" - And I set the field "Grade out of 100" to "53" - And I set the field "Notify student" to "0" - And I press "Save changes" - And I am on "Course 1" course homepage with editing mode on - And I add the "Course completion status" block + Scenario: Completion status block with items with passing grade + Given I am on the "Course 1" course page logged in as teacher1 And I navigate to "Course completion" in current page administration And I expand all fieldsets And I set the following fields to these values: | Test assign name | 1 | And I press "Save changes" - And I log out - When I am on the "Test assign name" "assign activity" page logged in as student1 - And I am on "Course 1" course homepage - Then I should see "Status: Pending" in the "Course completion status" "block" - And I should see "0 of 1" in the "Activity completion" "table_row" + And the following "grade grades" exist: + | gradeitem | user | grade | + | Test assign name | student1 | 53 | + When I am on the "Course 1" course page logged in as student1 + Then I should see "Status: Complete" in the "Course completion status" "block" + And I should see "1 of 1" in the "Activity completion" "table_row" And I trigger cron And I am on "Course 1" course homepage And I follow "More details" And I should see "Achieving grade, Achieving passing grade" in the "Activity completion" "table_row" And I should see "Yes" in the "Activity completion" "table_row" - @javascript - Scenario: Add the block to a the course and add course completion items with failing grade. - Given I am on the "Test assign name" "assign activity" page logged in as teacher1 - And I navigate to "Settings" in current page administration - And I set the following fields to these values: - | Completion tracking | Show activity as complete when conditions are met | - | completionusegrade | 1 | - | completionpassgrade | 1 | - | gradepass | 50 | - And I press "Save and return to course" - And I am on the "Test assign name" "assign activity" page - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student" "table_row" - And I set the field "Grade out of 100" to "49" - And I set the field "Notify student" to "0" - And I press "Save changes" - And I am on "Course 1" course homepage with editing mode on - And I add the "Course completion status" block + Scenario: Completion status block with items with failing grade + Given I am on the "Course 1" course page logged in as teacher1 + And the following "grade grades" exist: + | gradeitem | user | grade | + | Test assign name | student1 | 49 | And I navigate to "Course completion" in current page administration And I expand all fieldsets And I set the following fields to these values: | Test assign name | 1 | And I press "Save changes" - And I log out - When I am on the "Test assign name" "assign activity" page logged in as student1 - And I am on "Course 1" course homepage - Then I should see "Status: Pending" in the "Course completion status" "block" + When I am on the "Course 1" course page logged in as student1 + Then I should see "Status: Not yet started" in the "Course completion status" "block" And I should see "0 of 1" in the "Activity completion" "table_row" And I trigger cron And I am on "Course 1" course homepage diff --git a/cache/classes/loaders.php b/cache/classes/loaders.php index 3d650feb56488..a03ac44d53847 100644 --- a/cache/classes/loaders.php +++ b/cache/classes/loaders.php @@ -608,8 +608,7 @@ protected function get_implementation($key, int $requiredversion, int $strictnes // store; parent method will have set it to all stores if needed. if ($setaftervalidation) { $lock = null; - // Only try to acquire a lock for this cache if we do not already have one. - if (!empty($this->requirelockingbeforewrite) && !$this->check_lock_state($key)) { + if (!empty($this->requirelockingbeforewrite)) { $lock = $this->acquire_lock($key); } if ($requiredversion === self::VERSION_NONE) { @@ -1676,24 +1675,34 @@ public function __clone() { * @return bool Returns true if the lock could be acquired, false otherwise. */ public function acquire_lock($key) { - global $CFG; + $releaseparent = false; if ($this->get_loader() !== false) { - $this->get_loader()->acquire_lock($key); + if (!$this->get_loader()->acquire_lock($key)) { + return false; + } + // We need to release this lock later if the lock is not successful. + $releaseparent = true; } - $key = cache_helper::hash_key($key, $this->get_definition()); + $hashedkey = cache_helper::hash_key($key, $this->get_definition()); $before = microtime(true); if ($this->nativelocking) { - $lock = $this->get_store()->acquire_lock($key, $this->get_identifier()); + $lock = $this->get_store()->acquire_lock($hashedkey, $this->get_identifier()); } else { $this->ensure_cachelock_available(); - $lock = $this->cachelockinstance->lock($key, $this->get_identifier()); + $lock = $this->cachelockinstance->lock($hashedkey, $this->get_identifier()); } $after = microtime(true); if ($lock) { - $this->locks[$key] = $lock; + $this->locks[$hashedkey] = $lock; if ((defined('MDL_PERF') && MDL_PERF) || $this->perfdebug) { \core\lock\timing_wrapper_lock_factory::record_lock_data($after, $before, - $this->get_definition()->get_id(), $key, $lock, $this->get_identifier() . $key); + $this->get_definition()->get_id(), $hashedkey, $lock, $this->get_identifier() . $hashedkey); + } + } else { + // If we successfully got the parent lock, but are now failing to get this lock, then we should release + // the parent one. + if ($releaseparent) { + $this->get_loader()->release_lock($key); } } return $lock; diff --git a/cache/tests/cache_test.php b/cache/tests/cache_test.php index 0c6cff0412cdb..bcd99cc191637 100644 --- a/cache/tests/cache_test.php +++ b/cache/tests/cache_test.php @@ -2274,6 +2274,93 @@ public function test_application_locking_multiple_layers() { $this->assertEquals(['x' => 'X', 'y' => 'Y', 'z' => 'Z'], $cache->get_many(['x', 'y', 'z'])); } + /** + * Tests that locking fails correctly when either layer of a 2-layer cache has a lock already. + * + * @covers \cache_loader + */ + public function test_application_locking_multiple_layers_failures(): void { + + $instance = cache_config_testing::instance(true); + $instance->phpunit_add_definition('phpunit/test_application_locking', array( + 'mode' => cache_store::MODE_APPLICATION, + 'component' => 'phpunit', + 'area' => 'test_application_locking', + 'staticacceleration' => true, + 'staticaccelerationsize' => 1, + 'requirelockingbeforewrite' => true + ), false); + $instance->phpunit_add_file_store('phpunittest1'); + $instance->phpunit_add_file_store('phpunittest2'); + $instance->phpunit_add_definition_mapping('phpunit/test_application_locking', 'phpunittest1', 1); + $instance->phpunit_add_definition_mapping('phpunit/test_application_locking', 'phpunittest2', 2); + + $cache = cache::make('phpunit', 'test_application_locking'); + + // We need to get the individual stores so as to set up the right behaviour here. + $ref = new \ReflectionClass('\cache'); + $definitionprop = $ref->getProperty('definition'); + $definitionprop->setAccessible(true); + $storeprop = $ref->getProperty('store'); + $storeprop->setAccessible(true); + $loaderprop = $ref->getProperty('loader'); + $loaderprop->setAccessible(true); + + $definition = $definitionprop->getValue($cache); + $localstore = $storeprop->getValue($cache); + $sharedcache = $loaderprop->getValue($cache); + $sharedstore = $storeprop->getValue($sharedcache); + + // Set the lock waiting time to 1 second so it doesn't take forever to run the test. + $ref = new \ReflectionClass('\cachestore_file'); + $lockwaitprop = $ref->getProperty('lockwait'); + $lockwaitprop->setAccessible(true); + + $lockwaitprop->setValue($localstore, 1); + $lockwaitprop->setValue($sharedstore, 1); + + // Get key details and the cache identifier. + $hashedkey = cache_helper::hash_key('apple', $definition); + $localidentifier = $cache->get_identifier(); + $sharedidentifier = $sharedcache->get_identifier(); + + // 1. Local cache is not locked but parent cache is locked. + $sharedstore->acquire_lock($hashedkey, 'somebodyelse'); + try { + $this->assertFalse($cache->acquire_lock('apple')); + + // Neither store is locked by us, shared store still locked. + $this->assertFalse((bool)$localstore->check_lock_state($hashedkey, $localidentifier)); + $this->assertFalse((bool)$sharedstore->check_lock_state($hashedkey, $sharedidentifier)); + $this->assertTrue((bool)$sharedstore->check_lock_state($hashedkey, 'somebodyelse')); + + } finally { + $sharedstore->release_lock($hashedkey, 'somebodyelse'); + } + + // 2. Local cache is locked, parent cache is not locked. + $localstore->acquire_lock($hashedkey, 'somebodyelse'); + try { + $this->assertFalse($cache->acquire_lock('apple')); + + // Neither store is locked by us, local store still locked. + $this->assertFalse((bool)$localstore->check_lock_state($hashedkey, $localidentifier)); + $this->assertFalse((bool)$sharedstore->check_lock_state($hashedkey, $sharedidentifier)); + $this->assertTrue((bool)$localstore->check_lock_state($hashedkey, 'somebodyelse')); + } finally { + $localstore->release_lock($hashedkey, 'somebodyelse'); + } + + // 3. Just for completion, test what happens if we do lock it. + $this->assertTrue($cache->acquire_lock('apple')); + try { + $this->assertTrue((bool)$localstore->check_lock_state($hashedkey, $localidentifier)); + $this->assertTrue((bool)$sharedstore->check_lock_state($hashedkey, $sharedidentifier)); + } finally { + $cache->release_lock('apple'); + } + } + /** * Test the static cache_helper method purge_stores_used_by_definition. */ diff --git a/calendar/classes/type_base.php b/calendar/classes/type_base.php index 75e4bad89c280..223c59f068e88 100644 --- a/calendar/classes/type_base.php +++ b/calendar/classes/type_base.php @@ -165,7 +165,7 @@ public abstract function get_next_month($year, $month); * @param int $time the timestamp in UTC, as obtained from the database * @param string $format strftime format * @param int|float|string $timezone the timezone to use - * {@link http://docs.moodle.org/dev/Time_API#Timezone} + * {@link https://moodledev.io/docs/apis/subsystems/time#timezone} * @param bool $fixday if true then the leading zero from %d is removed, * if false then the leading zero is maintained * @param bool $fixhour if true then the leading zero from %I is removed, @@ -180,7 +180,7 @@ public abstract function timestamp_to_date_string($time, $format, $timezone, $fi * * @param int $time timestamp in GMT * @param float|int|string $timezone the timezone to use to calculate the time - * {@link http://docs.moodle.org/dev/Time_API#Timezone} + * {@link https://moodledev.io/docs/apis/subsystems/time#timezone} * @return array an array that represents the date in user time */ public abstract function timestamp_to_date_array($time, $timezone = 99); diff --git a/calendar/templates/day_navigation.mustache b/calendar/templates/day_navigation.mustache index d2c7ecad8fc73..065d32532ff41 100644 --- a/calendar/templates/day_navigation.mustache +++ b/calendar/templates/day_navigation.mustache @@ -41,7 +41,7 @@ }} data-month="{{previousperiod.mon}}"{{! }} data-day="{{previousperiod.mday}}"{{! }}> - {{{larrow}}} +   {{previousperiodname}} @@ -58,7 +58,7 @@ }}> {{nextperiodname}}   - {{{rarrow}}} + diff --git a/calendar/templates/event_icon.mustache b/calendar/templates/event_icon.mustache index ab58936a82e3e..133d071f502a3 100644 --- a/calendar/templates/event_icon.mustache +++ b/calendar/templates/event_icon.mustache @@ -34,7 +34,17 @@ } }} {{#modulename}} - {{#pix}} monologo, {{modulename}} {{/pix}} + {{#icon}} + {{#iconurl}} + {{alttext}} + {{/iconurl}} + {{^iconurl}} + {{#pix}} monologo, {{modulename}} {{/pix}} + {{/iconurl}} + {{/icon}} + {{^icon}} + {{#pix}} monologo, {{modulename}} {{/pix}} + {{/icon}} {{/modulename}} {{^modulename}} {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} diff --git a/calendar/templates/event_item.mustache b/calendar/templates/event_item.mustache index 2ac7c811f4e0f..abb2054e36c5d 100644 --- a/calendar/templates/event_item.mustache +++ b/calendar/templates/event_item.mustache @@ -75,7 +75,16 @@ {{/isactionevent}} {{/canedit}} - {{#icon}}
      {{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}
      {{/icon}} + {{#icon}} +
      + {{#iconurl}} + {{alttext}} + {{/iconurl}} + {{^iconurl}} + {{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}} + {{/iconurl}} +
      + {{/icon}}

      {{{name}}}

      diff --git a/calendar/templates/month_navigation.mustache b/calendar/templates/month_navigation.mustache index 89dcfcf13bced..946a0c7fa99d2 100644 --- a/calendar/templates/month_navigation.mustache +++ b/calendar/templates/month_navigation.mustache @@ -41,7 +41,7 @@ }} data-month="{{previousperiod.mon}}"{{! }} data-drop-zone="nav-link" {{! }}> - {{{larrow}}} +   {{previousperiodname}} @@ -63,7 +63,7 @@ }}> {{nextperiodname}}   - {{{rarrow}}} + diff --git a/calendar/tests/calendartype_test_example.php b/calendar/tests/calendartype_test_example.php index ab725d1d03576..21e9149ddb2ea 100644 --- a/calendar/tests/calendartype_test_example.php +++ b/calendar/tests/calendartype_test_example.php @@ -222,7 +222,7 @@ public function get_next_month($year, $month) { * @param int $time the timestamp in UTC, as obtained from the database * @param string $format strftime format * @param int|float|string $timezone the timezone to use - * {@link http://docs.moodle.org/dev/Time_API#Timezone} + * {@link https://moodledev.io/docs/apis/subsystems/time#timezone} * @param bool $fixday if true then the leading zero from %d is removed, * if false then the leading zero is maintained * @param bool $fixhour if true then the leading zero from %I is removed, @@ -239,7 +239,7 @@ public function timestamp_to_date_string($time, $format, $timezone, $fixday, $fi * * @param int $time timestamp in GMT * @param float|int|string $timezone the timezone to use to calculate the time - * {@link http://docs.moodle.org/dev/Time_API#Timezone} + * {@link https://moodledev.io/docs/apis/subsystems/time#timezone} * @return array an array that represents the date in user time */ public function timestamp_to_date_array($time, $timezone = 99) { diff --git a/calendar/type/gregorian/classes/structure.php b/calendar/type/gregorian/classes/structure.php index bb0012666b225..a01e84b1c1dbb 100644 --- a/calendar/type/gregorian/classes/structure.php +++ b/calendar/type/gregorian/classes/structure.php @@ -289,7 +289,7 @@ public function get_next_month($year, $month) { * @param int $time the timestamp in UTC, as obtained from the database * @param string $format strftime format * @param int|float|string $timezone the timezone to use - * {@link http://docs.moodle.org/dev/Time_API#Timezone} + * {@link https://moodledev.io/docs/apis/subsystems/time#timezone} * @param bool $fixday if true then the leading zero from %d is removed, * if false then the leading zero is maintained * @param bool $fixhour if true then the leading zero from %I is removed, @@ -348,7 +348,7 @@ public function timestamp_to_date_string($time, $format, $timezone, $fixday, $fi * * @param int $time Timestamp in GMT * @param float|int|string $timezone offset's time with timezone, if float and not 99, then no - * dst offset is applied {@link http://docs.moodle.org/dev/Time_API#Timezone} + * dst offset is applied {@link https://moodledev.io/docs/apis/subsystems/time#timezone} * @return array an array that represents the date in user time */ public function timestamp_to_date_array($time, $timezone = 99) { diff --git a/calendar/upgrade.txt b/calendar/upgrade.txt index 79c554796e86d..376d0e67c5861 100644 --- a/calendar/upgrade.txt +++ b/calendar/upgrade.txt @@ -30,7 +30,7 @@ information provided here is intended especially for developers. === 3.9 === * Plugins can now create their own calendar events, both standard and action ones. To do it they need to specify $event->component when creating an event. Component events can not be edited or deleted manually. - See https://docs.moodle.org/dev/Calendar_API#Component_events + See https://moodledev.io/docs/apis/core/calendar#component-events * The following functions have been deprecated because they were no longer used: - calendar_add_event_metadata() - core_calendar_renderer::event() diff --git a/completion/criteria/completion_criteria_activity.php b/completion/criteria/completion_criteria_activity.php index a4e5ede9b65b2..b4f78d841a312 100644 --- a/completion/criteria/completion_criteria_activity.php +++ b/completion/criteria/completion_criteria_activity.php @@ -155,8 +155,8 @@ public function review($completion, $mark = true) { $data = $info->get_data($cm, false, $completion->userid); - // If the activity is complete - if (in_array($data->completionstate, array(COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE_FAIL))) { + // If the activity is complete. + if (in_array($data->completionstate, [COMPLETION_COMPLETE, COMPLETION_COMPLETE_PASS])) { if ($mark) { $completion->mark_complete(); } diff --git a/completion/tests/behat/course_completion_activity_criteria.feature b/completion/tests/behat/course_completion_activity_criteria.feature index 5b3fd4154329b..c993cec93dd50 100644 --- a/completion/tests/behat/course_completion_activity_criteria.feature +++ b/completion/tests/behat/course_completion_activity_criteria.feature @@ -1,4 +1,4 @@ -@block @block_completionstatus @javascript +@block @block_completionstatus @core_completion @javascript Feature: Course completion state should match completion criteria In order to understand the configuration or status of an course's completion As a user @@ -8,7 +8,6 @@ Feature: Course completion state should match completion criteria Given the following "users" exist: | username | firstname | lastname | email | idnumber | | teacher1 | Teacher | 1 | teacher1@example.com | T1 | - | teacher2 | Teacher | 2 | teacher1@example.com | T2 | | student1 | Student | 1 | student1@example.com | S1 | And the following "courses" exist: | fullname | shortname | category | enablecompletion | showcompletionconditions | @@ -16,16 +15,18 @@ Feature: Course completion state should match completion criteria And the following "course enrolments" exist: | user | course | role | | teacher1 | C1 | editingteacher | - | teacher2 | C1 | teacher | | student1 | C1 | student | And the following "activity" exists: | activity | assign | | course | C1 | | name | Test assignment name | - | completion | 1 | | assignsubmission_onlinetext_enabled | 1 | - | grade[modgrade_type] | Point | - | grade[modgrade_point] | 100 | + | grade[modgrade_type] | Point | + | grade[modgrade_point] | 100 | + | gradepass | 70 | + | completion | 2 | + | completionusegrade | 1 | + | completionpassgrade | 1 | And the following "blocks" exist: | blockname | contextlevel | reference | pagetypepattern | defaultregion | | completionstatus | Course | C1 | course-view-* | side-pre | @@ -34,58 +35,36 @@ Feature: Course completion state should match completion criteria And I click on "Condition: Activity completion" "link" And I set the field "Assignment - Test assignment name" to "1" And I press "Save changes" - And I am on the "Test assignment name" "assign activity editing" page - And I set the following fields to these values: - | Completion tracking | Show activity as complete when conditions are met | - | completionusegrade | 1 | - | completionpassgrade | 1 | - | gradepass | 70 | - And I press "Save and return to course" Scenario: Completion status show match completion criteria when passgrage condition is set. Given I am on the "Course 1" course page logged in as "student1" And the "Receive a grade" completion condition of "Test assignment name" is displayed as "todo" And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "todo" And I should see "Status: Not yet started" in the "Course completion status" "block" - And I am on the "Test assignment name" "assign activity" page - And I press "Add submission" - And I set the following fields to these values: - | Online text | I'm the student1 submission | - And I press "Save changes" - And I press "Submit assignment" - And I press "Continue" - And I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student 1" "table_row" - And I set the following fields to these values: - | Grade out of 100 | 50.0 | - And I press "Save changes" - And I am on the "Course 1" course page - And I navigate to "Reports" in current page administration - And I click on "Activity completion" "link" - And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" - And I navigate to "Reports" in current page administration - And I click on "Course completion" "link" in the "region-main" "region" - And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" - And "Student 1, Course complete: Not completed" "icon" should exist in the "Student 1" "table_row" - When I am on the "Course 1" course page logged in as "student1" - And I should see "Status: Pending" in the "Course completion status" "block" + When the following "mod_assign > submissions" exist: + | assign | user | onlinetext | + | Test assignment name | student1 | This is a submission for assignment | + And the following "grade grades" exist: + | gradeitem | user | grade | + | Test assignment name | student1 | 50 | + And I reload the page + Then I should see "Status: Not yet started" in the "Course completion status" "block" And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "failed" And I am on the "My courses" page And I should not see "100%" in the "Course overview" "block" - And I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student 1" "table_row" - And I set the following fields to these values: - | Grade out of 100 | 75.0 | - And I press "Save changes" - And I am on the "Course 1" course page - And I navigate to "Reports" in current page administration - And I click on "Activity completion" "link" + And I am on the "Course 1" course page logged in as teacher1 + And I navigate to "Reports > Activity completion" in current page administration + And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" + And I navigate to "Reports > Course completion" in current page administration + And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" + And "Student 1, Course complete: Not completed" "icon" should exist in the "Student 1" "table_row" + And the following "grade grades" exist: + | gradeitem | user | grade | + | Test assignment name | student1 | 75 | + And I navigate to "Reports > Activity completion" in current page administration And "Student 1, Test assignment name: Completed (achieved pass grade)" "icon" should exist in the "Student 1" "table_row" - And I navigate to "Reports" in current page administration - And I click on "Course completion" "link" in the "region-main" "region" + And I navigate to "Reports > Course completion" in current page administration And "Student 1, Test assignment name: Completed (achieved pass grade)" "icon" should exist in the "Student 1" "table_row" And "Student 1, Course complete: Completed" "icon" should exist in the "Student 1" "table_row" And I am on the "Course 1" course page logged in as "student1" @@ -93,62 +72,46 @@ Feature: Course completion state should match completion criteria And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done" And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "done" And I am on the "My courses" page - Then I should see "100%" in the "Course overview" "block" + And I should see "100%" in the "Course overview" "block" Scenario: Completion status show match completion criteria when passgrage condition is not set. Given I am on the "Test assignment name" "assign activity editing" page logged in as teacher1 And I set the following fields to these values: - | Completion tracking | Show activity as complete when conditions are met | - | completionusegrade | 1 | - | completionpassgrade | 0 | - | gradepass | 70 | + | completionpassgrade | 0 | And I press "Save and return to course" And I am on the "Course 1" course page logged in as "student1" And the "Receive a grade" completion condition of "Test assignment name" is displayed as "todo" And I should see "Status: Not yet started" in the "Course completion status" "block" - And I am on the "Test assignment name" "assign activity" page - And I press "Add submission" - And I set the following fields to these values: - | Online text | I'm the student1 submission | - And I press "Save changes" - And I press "Submit assignment" - And I press "Continue" - And I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student 1" "table_row" - And I set the following fields to these values: - | Grade out of 100 | 50.0 | - And I press "Save changes" - And I am on the "Course 1" course page - And I navigate to "Reports" in current page administration - And I click on "Activity completion" "link" - And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" - And I navigate to "Reports" in current page administration - And I click on "Course completion" "link" in the "region-main" "region" - And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" - And "Student 1, Course complete: Completed" "icon" should exist in the "Student 1" "table_row" - When I am on the "Course 1" course page logged in as "student1" - And I should see "Status: Complete" in the "Course completion status" "block" + When the following "mod_assign > submissions" exist: + | assign | user | onlinetext | + | Test assignment name | student1 | I'm the student1 submission | + And the following "grade grades" exist: + | gradeitem | user | grade | + | Test assignment name | student1 | 50 | + And I reload the page + # TODO: Expected status is Complete but activity is marked as completed with a failed icon. + # Then I should see "Status: Complete" in the "Course completion status" "block" + Then I should see "Status: Pending" in the "Course completion status" "block" # Once MDL-75582 is fixed "failed" should be changed to "done" And the "Receive a grade" completion condition of "Test assignment name" is displayed as "failed" And I am on the "My courses" page And I should see "100%" in the "Course overview" "block" - And I am on the "Test assignment name" "assign activity" page logged in as teacher1 - And I follow "View all submissions" - And I click on "Grade" "link" in the "Student 1" "table_row" - And I set the following fields to these values: - | Grade out of 100 | 75.0 | - And I press "Save changes" - And I am on the "Course 1" course page - And I navigate to "Reports" in current page administration - And I click on "Activity completion" "link" + And I am on the "Course 1" course page logged in as teacher1 + And I navigate to "Reports > Activity completion" in current page administration + And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" + And I navigate to "Reports > Course completion" in current page administration + And "Student 1, Test assignment name: Completed (did not achieve pass grade)" "icon" should exist in the "Student 1" "table_row" + And "Student 1, Course complete: Completed" "icon" should exist in the "Student 1" "table_row" + And the following "grade grades" exist: + | gradeitem | user | grade | + | Test assignment name | student1 | 75 | + And I navigate to "Reports > Activity completion" in current page administration And "Student 1, Test assignment name: Completed (achieved pass grade)" "icon" should exist in the "Student 1" "table_row" - And I navigate to "Reports" in current page administration - And I click on "Course completion" "link" in the "region-main" "region" + And I navigate to "Reports > Course completion" in current page administration And "Student 1, Test assignment name: Completed (achieved pass grade)" "icon" should exist in the "Student 1" "table_row" And "Student 1, Course complete: Completed" "icon" should exist in the "Student 1" "table_row" And I am on the "Course 1" course page logged in as "student1" And I should see "Status: Complete" in the "Course completion status" "block" And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done" And I am on the "My courses" page - Then I should see "100%" in the "Course overview" "block" + And I should see "100%" in the "Course overview" "block" diff --git a/contentbank/tests/behat/delete_content.feature b/contentbank/tests/behat/delete_content.feature index 2e639898f4304..5a2a801c697ca 100644 --- a/contentbank/tests/behat/delete_content.feature +++ b/contentbank/tests/behat/delete_content.feature @@ -43,7 +43,7 @@ Feature: Delete H5P file from the content bank And I click on "Delete" "link" in the ".cb-toolbar-container" "css_element" And I click on "Delete" "button" in the "Delete content" "dialogue" And I wait until the page is ready - And I should see "The content has been deleted." + And I should see "Content deleted." And I should not see "content2delete.h5p" Scenario: Users without the required capability can only delete their own content @@ -94,5 +94,5 @@ Feature: Delete H5P file from the content bank Then I should see "Are you sure you want to delete the content 'content2delete.h5p'" And I should see "The content will only be deleted from the content bank" And I click on "Delete" "button" in the "Delete content" "dialogue" - And I should see "The content has been deleted." + And I should see "Content deleted." And I should not see "content2delete.h5p" diff --git a/course/externallib.php b/course/externallib.php index 0a374127f5b5b..2af1e72a15411 100644 --- a/course/externallib.php +++ b/course/externallib.php @@ -2144,6 +2144,19 @@ public static function update_categories($categories) { self::validate_context($categorycontext); require_capability('moodle/category:manage', $categorycontext); + // If the category parent is being changed, check for capability in the new parent category + if (isset($cat['parent']) && ($cat['parent'] !== $category->parent)) { + if ($cat['parent'] == 0) { + // Creating a top level category requires capability in the system context + $parentcontext = context_system::instance(); + } else { + // Category context + $parentcontext = context_coursecat::instance($cat['parent']); + } + self::validate_context($parentcontext); + require_capability('moodle/category:manage', $parentcontext); + } + // this will throw an exception if descriptionformat is not valid external_validate_format($cat['descriptionformat']); diff --git a/course/format/classes/base.php b/course/format/classes/base.php index 30e7f30085838..291072cfcabe2 100644 --- a/course/format/classes/base.php +++ b/course/format/classes/base.php @@ -371,7 +371,7 @@ public function get_modinfo(): course_modinfo { * This method ensures that 3rd party course format plugins that still use 'numsections' continue to * work but at the same time we no longer expect formats to have 'numsections' property. * - * @return int + * @return int The last section number, or -1 if sections are entirely missing */ public function get_last_section_number() { $course = $this->get_course(); @@ -380,6 +380,12 @@ public function get_last_section_number() { } $modinfo = get_fast_modinfo($course); $sections = $modinfo->get_section_info_all(); + + // Sections seem to be missing entirely. Avoid subsequent errors and return early. + if (count($sections) === 0) { + return -1; + } + return (int)max(array_keys($sections)); } diff --git a/course/format/tests/base_test.php b/course/format/tests/base_test.php index 55ca4d7c6022c..c6a1f6d1ece63 100644 --- a/course/format/tests/base_test.php +++ b/course/format/tests/base_test.php @@ -14,14 +14,13 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -defined('MOODLE_INTERNAL') || die(); - /** * Course related unit tests * * @package core_course * @copyright 2014 Marina Glancy * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_courseformat\base */ class base_test extends advanced_testcase { @@ -348,6 +347,32 @@ public function test_set_sections_preference() { ); } + /** + * Test that retrieving last section number for a course + * + * @covers ::get_last_section_number + */ + public function test_get_last_section_number(): void { + global $DB; + + $this->resetAfterTest(); + + // Course with two additional sections. + $courseone = $this->getDataGenerator()->create_course(['numsections' => 2]); + $this->assertEquals(2, course_get_format($courseone)->get_last_section_number()); + + // Course without additional sections, section zero is the "default" section that always exists. + $coursetwo = $this->getDataGenerator()->create_course(['numsections' => 0]); + $this->assertEquals(0, course_get_format($coursetwo)->get_last_section_number()); + + // Course without additional sections, manually remove section zero, as "course_delete_section" prevents that. This + // simulates course data integrity issues that previously triggered errors. + $coursethree = $this->getDataGenerator()->create_course(['numsections' => 0]); + $DB->delete_records('course_sections', ['course' => $coursethree->id, 'section' => 0]); + + $this->assertEquals(-1, course_get_format($coursethree)->get_last_section_number()); + } + /** * Test for the default delete format data behaviour. * diff --git a/course/format/upgrade.txt b/course/format/upgrade.txt index 38220c87823bd..68aed30b32169 100644 --- a/course/format/upgrade.txt +++ b/course/format/upgrade.txt @@ -1,6 +1,6 @@ This files describes API changes for course formats -Overview of this plugin type at http://docs.moodle.org/dev/Course_formats +Overview of this plugin type at https://moodledev.io/docs/apis/plugintypes/format === 4.1 === * New \core_courseformat\stateupdates methods add_section_remove() and add_cm_remove() have been added to replace diff --git a/course/format/weeks/lib.php b/course/format/weeks/lib.php index 3bd726b4fc35a..79b0b119fb41b 100644 --- a/course/format/weeks/lib.php +++ b/course/format/weeks/lib.php @@ -406,14 +406,25 @@ public function get_section_dates($section, $startdate = false) { } else { $sectionnum = $section; } - $oneweekseconds = 604800; - // Hack alert. We add 2 hours to avoid possible DST problems. (e.g. we go into daylight - // savings and the date changes. - $startdate = $startdate + 7200; + + // Create a DateTime object for the start date. + $startdateobj = new DateTime("@$startdate"); + + // Calculate the interval for one week. + $oneweekinterval = new DateInterval('P7D'); + + // Calculate the interval for the specified number of sections. + for ($i = 1; $i < $sectionnum; $i++) { + $startdateobj->add($oneweekinterval); + } + + // Calculate the end date. + $enddateobj = clone $startdateobj; + $enddateobj->add($oneweekinterval); $dates = new stdClass(); - $dates->start = $startdate + ($oneweekseconds * ($sectionnum - 1)); - $dates->end = $dates->start + $oneweekseconds; + $dates->start = $startdateobj->getTimestamp(); + $dates->end = $enddateobj->getTimestamp(); return $dates; } diff --git a/course/format/weeks/tests/format_weeks_test.php b/course/format/weeks/tests/format_weeks_test.php index dd5eb990a366c..8d6414a2a0c53 100644 --- a/course/format/weeks/tests/format_weeks_test.php +++ b/course/format/weeks/tests/format_weeks_test.php @@ -219,8 +219,8 @@ public function test_default_course_enddate() { $courseform = new \testable_course_edit_form(null, $args); $courseform->definition_after_data(); - // format_weeks::get_section_dates is adding 2h to avoid DST problems, we need to replicate it here. - $enddate = $params['startdate'] + (WEEKSECS * $params['numsections']) + 7200; + // Calculate the expected end date. + $enddate = $params['startdate'] + (WEEKSECS * $params['numsections']); $weeksformat = course_get_format($course->id); $this->assertEquals($enddate, $weeksformat->get_default_course_enddate($courseform->get_quick_form())); diff --git a/course/renderer.php b/course/renderer.php index 70c2bc8ca9861..ed3eefee1345d 100644 --- a/course/renderer.php +++ b/course/renderer.php @@ -97,8 +97,6 @@ public function course_info_box(stdClass $course) { * * @deprecated since 2.5 * - * Please see http://docs.moodle.org/dev/Courses_lists_upgrade_to_2.5 - * * @param array $ignored argument ignored * @return string */ @@ -112,8 +110,6 @@ public final function course_category_tree(array $ignored) { * * @deprecated since 2.5 * - * Please see http://docs.moodle.org/dev/Courses_lists_upgrade_to_2.5 - * * @param array $category * @param int $depth * @return string diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php index ca348327bf08e..b1651797d04af 100644 --- a/course/tests/externallib_test.php +++ b/course/tests/externallib_test.php @@ -363,6 +363,44 @@ public function test_update_categories() { core_course_external::update_categories($categories); } + /** + * Test update_categories method for moving categories + */ + public function test_update_categories_moving() { + $this->resetAfterTest(); + + // Create data. + $categorya = self::getDataGenerator()->create_category([ + 'name' => 'CAT_A', + ]); + $categoryasub = self::getDataGenerator()->create_category([ + 'name' => 'SUBCAT_A', + 'parent' => $categorya->id + ]); + $categoryb = self::getDataGenerator()->create_category([ + 'name' => 'CAT_B', + ]); + + // Create a new test user. + $testuser = self::getDataGenerator()->create_user(); + $this->setUser($testuser); + + // Set the capability for CAT_A only. + $contextcata = context_coursecat::instance($categorya->id); + $roleid = $this->assignUserCapability('moodle/category:manage', $contextcata->id); + + // Then we move SUBCAT_A parent: CAT_A => CAT_B. + $categories = [ + [ + 'id' => $categoryasub->id, + 'parent' => $categoryb->id + ] + ]; + + $this->expectException('required_capability_exception'); + core_course_external::update_categories($categories); + } + /** * Test create_courses numsections */ diff --git a/customfield/classes/data_controller.php b/customfield/classes/data_controller.php index bd19792bd5f43..49acf650953a2 100644 --- a/customfield/classes/data_controller.php +++ b/customfield/classes/data_controller.php @@ -238,7 +238,13 @@ protected function is_empty($value) : bool { */ protected function is_unique($value) : bool { global $DB; + + // Ensure the "value" datafield can be safely compared across all databases. $datafield = $this->datafield(); + if ($datafield === 'value') { + $datafield = $DB->sql_cast_to_char($datafield); + } + $where = "fieldid = ? AND {$datafield} = ?"; $params = [$this->get_field()->get('id'), $value]; if ($this->get('id')) { diff --git a/customfield/field/textarea/classes/data_controller.php b/customfield/field/textarea/classes/data_controller.php index 64e62d1fce7a7..3d52fe15bc4d7 100644 --- a/customfield/field/textarea/classes/data_controller.php +++ b/customfield/field/textarea/classes/data_controller.php @@ -136,6 +136,29 @@ public function instance_form_before_set_data(\stdClass $instance) { $instance->{$this->get_form_element_name()} = $value; } + /** + * Checks if the value is empty, overriding the base method to ensure it's the "text" element of our value being compared + * + * @param mixed $value + * @return bool + */ + protected function is_empty($value): bool { + if (is_array($value)) { + $value = $value['text']; + } + return html_is_blank($value); + } + + /** + * Checks if the value is unique, overriding the base method to ensure it's the "text" element of our value being compared + * + * @param mixed $value + * @return bool + */ + protected function is_unique($value): bool { + return parent::is_unique($value['text']); + } + /** * Delete data * @@ -166,9 +189,6 @@ public function export_value() { require_once($CFG->libdir . '/filelib.php'); $value = $this->get_value(); - if ($this->is_empty($value)) { - return null; - } if ($dataid = $this->get('id')) { $context = $this->get_context(); diff --git a/enrol/locallib.php b/enrol/locallib.php index 798f789edc7e9..4b75324edc527 100644 --- a/enrol/locallib.php +++ b/enrol/locallib.php @@ -570,17 +570,28 @@ public function search_other_users($search = '', $searchanywhere = false, $page */ public function search_users(string $search = '', bool $searchanywhere = false, int $page = 0, int $perpage = 25, bool $returnexactcount = false) { + global $USER; + [$ufields, $joins, $params, $wherecondition] = $this->get_basic_search_conditions($search, $searchanywhere); + $groupmode = groups_get_course_groupmode($this->course); + if ($groupmode == SEPARATEGROUPS && !has_capability('moodle/site:accessallgroups', $this->context)) { + $groups = groups_get_all_groups($this->course->id, $USER->id, 0, 'g.id'); + $groupids = array_column($groups, 'id'); + } else { + $groupids = []; + } + + [$enrolledsql, $enrolledparams] = get_enrolled_sql($this->context, '', $groupids); + $fields = 'SELECT ' . $ufields; $countfields = 'SELECT COUNT(u.id)'; $sql = " FROM {user} u $joins - JOIN {user_enrolments} ue ON ue.userid = u.id - JOIN {enrol} e ON ue.enrolid = e.id - WHERE $wherecondition - AND e.courseid = :courseid"; - $params['courseid'] = $this->course->id; + JOIN ($enrolledsql) je ON je.id = u.id + WHERE $wherecondition"; + + $params = array_merge($params, $enrolledparams); return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, 0, $returnexactcount); } diff --git a/enrol/self/lang/en/enrol_self.php b/enrol/self/lang/en/enrol_self.php index 4479ae1c3c111..45e09ccec1c7c 100644 --- a/enrol/self/lang/en/enrol_self.php +++ b/enrol/self/lang/en/enrol_self.php @@ -79,7 +79,7 @@ $string['maxenrolled_help'] = 'Specifies the maximum number of users that can self enrol. 0 means no limit.'; $string['maxenrolledreached'] = 'Maximum number of users allowed to self-enrol was already reached.'; $string['messageprovider:expiry_notification'] = 'Self enrolment expiry notifications'; -$string['newenrols'] = 'Allow new enrolments'; +$string['newenrols'] = 'Allow new self enrolments'; $string['newenrols_desc'] = 'Allow users to self enrol into new courses by default.'; $string['newenrols_help'] = 'This setting determines whether a user can enrol into this course.'; $string['nopassword'] = 'No enrolment key required.'; @@ -92,6 +92,7 @@ $string['passwordinvalid'] = 'Incorrect enrolment key, please try again'; $string['passwordinvalidhint'] = 'That enrolment key was incorrect, please try again
      (Here\'s a hint - it starts with \'{$a}\')'; +$string['passwordmatchesgroupkey'] = 'Enrolment key matches an existing group enrolment key'; $string['pluginname'] = 'Self enrolment'; $string['pluginname_desc'] = 'The self enrolment plugin allows users to choose which courses they want to participate in. The courses may be protected by an enrolment key. Internally the enrolment is done via the manual enrolment plugin which has to be enabled in the same course.'; $string['requirepassword'] = 'Require enrolment key'; @@ -108,9 +109,9 @@ $string['sendexpirynotificationstask'] = "Self enrolment send expiry notifications task"; $string['showhint'] = 'Show hint'; $string['showhint_desc'] = 'Show first letter of the guest access key.'; -$string['status'] = 'Allow existing enrolments'; +$string['status'] = 'Keep current self enrolments active'; $string['status_desc'] = 'Enable self enrolment method in new courses.'; -$string['status_help'] = 'If enabled together with \'Allow new enrolments\' disabled, only users who self enrolled previously can access the course. If disabled, this self enrolment method is effectively disabled, since all existing self enrolments are suspended and new users cannot self enrol.'; +$string['status_help'] = 'If set to No, any existing participants who enrolled themselves into the course will no longer have access.'; $string['syncenrolmentstask'] = 'Synchronise self enrolments task'; $string['unenrol'] = 'Unenrol user'; $string['unenrolselfconfirm'] = 'Do you really want to unenrol yourself from course "{$a}"?'; diff --git a/enrol/self/lib.php b/enrol/self/lib.php index 1c931762e7f09..8bf5182502b15 100644 --- a/enrol/self/lib.php +++ b/enrol/self/lib.php @@ -159,8 +159,8 @@ public function enrol_self(stdClass $instance, $data = null) { \core\notification::success(get_string('youenrolledincourse', 'enrol')); - if ($instance->password and $instance->customint1 and $data->enrolpassword !== $instance->password) { - // It must be a group enrolment, let's assign group too. + // Test whether the password is also used as a group key. + if ($instance->password && $instance->customint1) { $groups = $DB->get_records('groups', array('courseid'=>$instance->courseid), 'id', 'id, enrolmentkey'); foreach ($groups as $group) { if (empty($group->enrolmentkey)) { @@ -876,6 +876,9 @@ public function use_standard_editing_ui() { * @return void */ public function edit_instance_validation($data, $files, $instance, $context) { + global $CFG; + require_once("{$CFG->dirroot}/enrol/self/locallib.php"); + $errors = array(); $checkpassword = false; @@ -890,6 +893,11 @@ public function edit_instance_validation($data, $files, $instance, $context) { if (($data['status'] == ENROL_INSTANCE_ENABLED) && ($instance->password !== $data['password'])) { $checkpassword = true; } + + // Check the password if we are enabling group enrolment keys. + if (!$instance->customint1 && $data['customint1']) { + $checkpassword = true; + } } else { $checkpassword = true; } @@ -904,6 +912,10 @@ public function edit_instance_validation($data, $files, $instance, $context) { if (!check_password_policy($data['password'], $errmsg)) { $errors['password'] = $errmsg; } + } else if (!empty($data['password']) && $data['customint1'] && + enrol_self_check_group_enrolment_key($data['courseid'], $data['password'])) { + + $errors['password'] = get_string('passwordmatchesgroupkey', 'enrol_self'); } } diff --git a/enrol/self/tests/self_test.php b/enrol/self/tests/self_test.php index 600c1ba10a3d5..351fdfeec348c 100644 --- a/enrol/self/tests/self_test.php +++ b/enrol/self/tests/self_test.php @@ -16,6 +16,9 @@ namespace enrol_self; +use context_course; +use enrol_self_plugin; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -29,6 +32,7 @@ * @category phpunit * @copyright 2012 Petr Skoda {@link http://skodak.org} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \enrol_self_plugin */ class self_test extends \advanced_testcase { @@ -761,6 +765,44 @@ public function test_is_self_enrol_available() { $this->assertEquals($canntenrolerror, $selfplugin->is_self_enrol_available($instance)); } + /** + * Test custom validation of instance data for group enrolment key + * + * @covers ::edit_instance_validation + */ + public function test_edit_instance_validation_group_enrolment_key(): void { + global $DB; + + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $context = context_course::instance($course->id); + + /** @var enrol_self_plugin $plugin */ + $plugin = enrol_get_plugin('self'); + + $instance = $DB->get_record('enrol', ['courseid' => $course->id, 'enrol' => $plugin->get_name()], '*', MUST_EXIST); + + // Enable group enrolment keys. + $errors = $plugin->edit_instance_validation([ + 'customint1' => 1, + 'password' => 'cat', + ] + (array) $instance, [], $instance, $context); + + $this->assertEmpty($errors); + + // Now create a group with the same enrolment key we want to use. + $this->getDataGenerator()->create_group(['courseid' => $course->id, 'enrolmentkey' => 'cat']); + + $errors = $plugin->edit_instance_validation([ + 'customint1' => 1, + 'password' => 'cat', + ] + (array) $instance, [], $instance, $context); + + $this->assertArrayHasKey('password', $errors); + $this->assertEquals('Enrolment key matches an existing group enrolment key', $errors['password']); + } + /** * Test enrol_self_check_group_enrolment_key */ diff --git a/enrol/tests/course_enrolment_manager_test.php b/enrol/tests/course_enrolment_manager_test.php index ce8d6a97f49b4..d20e6cae9a997 100644 --- a/enrol/tests/course_enrolment_manager_test.php +++ b/enrol/tests/course_enrolment_manager_test.php @@ -16,6 +16,7 @@ namespace core_enrol; +use context_course; use course_enrolment_manager; /** @@ -25,10 +26,11 @@ * @category test * @copyright 2016 Ruslan Kabalin, Lancaster University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \course_enrolment_manager */ class course_enrolment_manager_test extends \advanced_testcase { /** - * The course context used in tests. + * The course used in tests. * @var stdClass */ private $course = null; @@ -546,4 +548,57 @@ public function test_search_users($perpage, $returnexactcount, $expectedusers, $ $this->assertArrayNotHasKey('totalusers', $users); } } + + /** + * Test that search_users observes course group mode restrictions correctly + */ + public function test_search_users_course_groupmode(): void { + global $DB, $PAGE; + + $this->resetAfterTest(); + + $teacher = $this->getDataGenerator()->create_and_enrol($this->course, 'teacher'); + $this->getDataGenerator()->create_group_member(['groupid' => $this->groups['group1']->id, 'userid' => $teacher->id]); + $this->setUser($teacher); + + $users = (new course_enrolment_manager($PAGE, $this->course))->search_users('', false, 0, 25, true); + $this->assertEqualsCanonicalizing([ + $teacher->username, + $this->users['user0']->username, + $this->users['user1']->username, + $this->users['user21']->username, + $this->users['user22']->username, + $this->users['userall']->username, + $this->users['usertch']->username, + ], array_column($users['users'], 'username')); + $this->assertEquals(7, $users['totalusers']); + + // Switch course to separate groups. + $this->course->groupmode = SEPARATEGROUPS; + update_course($this->course); + + $users = (new course_enrolment_manager($PAGE, $this->course))->search_users('', false, 0, 25, true); + $this->assertEqualsCanonicalizing([ + $teacher->username, + $this->users['user1']->username, + $this->users['userall']->username, + ], array_column($users['users'], 'username')); + $this->assertEquals(3, $users['totalusers']); + + // Allow teacher to access all groups. + $roleid = $DB->get_field('role', 'id', ['shortname' => 'teacher']); + assign_capability('moodle/site:accessallgroups', CAP_ALLOW, $roleid, context_course::instance($this->course->id)->id); + + $users = (new course_enrolment_manager($PAGE, $this->course))->search_users('', false, 0, 25, true); + $this->assertEqualsCanonicalizing([ + $teacher->username, + $this->users['user0']->username, + $this->users['user1']->username, + $this->users['user21']->username, + $this->users['user22']->username, + $this->users['userall']->username, + $this->users['usertch']->username, + ], array_column($users['users'], 'username')); + $this->assertEquals(7, $users['totalusers']); + } } diff --git a/filter/urltolink/tests/filter_test.php b/filter/urltolink/tests/filter_test.php index 800453419014e..48e8766819225 100644 --- a/filter/urltolink/tests/filter_test.php +++ b/filter/urltolink/tests/filter_test.php @@ -176,7 +176,9 @@ function get_convert_urls_into_links_test_cases() { '

      text www.moodle.org</p> text' => '

      text www.moodle.org</p> text', // Some more urls. '' => '', - 'www.google.com' => 'www.google.com', + 'www.google.com' => + '' . + 'www.google.com', 'http://nolandforzombies.com Zombies FTW http://aliens.org' => 'http://nolandforzombies.com Zombies FTW http://aliens.org', // Test 'nolink' class. 'URL: http://moodle.org' => 'URL: http://moodle.org', diff --git a/grade/grading/form/guide/tests/behat/delete_marking_guide.feature b/grade/grading/form/guide/tests/behat/delete_marking_guide.feature new file mode 100644 index 0000000000000..a5e7b052576be --- /dev/null +++ b/grade/grading/form/guide/tests/behat/delete_marking_guide.feature @@ -0,0 +1,41 @@ +@gradingform @gradingform_guide +Feature: Teacher can delete marking guide + As a teacher, + I should be able to delete a marking guide + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | One | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "activities" exist: + | activity | course | name | advancedgradingmethod_submissions | + | assign | C1 | Assign 1 | guide | + And I am on the "Course 1" course page logged in as teacher1 + And I go to "Assign 1" advanced grading definition page + And I set the following fields to these values: + | Name | Marking guide 1 | + And I define the following marking guide: + | Criterion name | Description for students | Description for markers | Maximum score | + | Criterion 1 | Criterion 1 description for student | Criterion 1 description for markers | 100 | + And I press "Save marking guide and make it ready" + + Scenario: Delete a marking guide + Given I am on the "Course 1" course page + And I go to "Assign 1" advanced grading page + When I click on "Delete the currently defined form" "link" + And I press "Cancel" + # Confirm that marking guide was not deleted if Cancel is pressed + Then I should see "Marking guide 1 Ready for use" + And I should see "Criterion 1" + And I click on "Delete the currently defined form" "link" + And I press "Continue" + # Confirm that marking guide was deleted successfully if Continue is pressed + And I should see "Please note: the advanced grading form is not ready at the moment. Simple grading method will be used until the form has a valid status." + And I should not see "Marking guide 1 Ready for use" + And I should not see "Criterion 1" diff --git a/grade/import/csv/classes/output/renderer.php b/grade/import/csv/classes/output/renderer.php index b8750fd13bcd9..c8085696b7199 100644 --- a/grade/import/csv/classes/output/renderer.php +++ b/grade/import/csv/classes/output/renderer.php @@ -67,8 +67,10 @@ public function import_preview_page($header, $data) { $html = $this->output->heading(get_string('importpreview', 'grades')); $table = new html_table(); - $table->head = $header; - $table->data = $data; + $table->head = array_map('s', $header); + $table->data = array_map(static function($row) { + return array_map('s', $row); + }, $data); $html .= html_writer::table($table); return $html; diff --git a/grade/report/grader/styles.css b/grade/report/grader/styles.css index 37ffc5407a5fe..142056058b1ac 100644 --- a/grade/report/grader/styles.css +++ b/grade/report/grader/styles.css @@ -201,3 +201,9 @@ border: 1px solid #ccc; border-radius: 4px; } + +@media only screen and (min-width: 768px) { + .path-grade-report-grader .gradeparent table { + padding-right: 6em; + } +} diff --git a/grade/report/user/classes/report/user.php b/grade/report/user/classes/report/user.php index 6f099ca912b64..e27d667ce2b03 100644 --- a/grade/report/user/classes/report/user.php +++ b/grade/report/user/classes/report/user.php @@ -450,7 +450,7 @@ private function fill_table_recursive(array &$element) { $depth = $element['depth']; $gradeobject = $element['object']; $eid = $gradeobject->id; - $element['userid'] = $this->user->id; + $element['userid'] = $userid = $this->user->id; $fullname = $this->gtree->get_element_header($element, true, false, true, true, true); $data = []; $gradeitemdata = []; @@ -592,7 +592,7 @@ private function fill_table_recursive(array &$element) { if ($this->showweight) { $data['weight']['class'] = $class; $data['weight']['content'] = '-'; - $data['weight']['headers'] = "$headercat $headerrow weight"; + $data['weight']['headers'] = "$headercat $headerrow weight$userid"; // Has a weight assigned, might be extra credit. // This obliterates the weight because it provides a more informative description. @@ -679,7 +679,7 @@ private function fill_table_recursive(array &$element) { $gradegrade->grade_item, true); $gradeitemdata['graderaw'] = $gradeval; } - $data['grade']['headers'] = "$headercat $headerrow grade"; + $data['grade']['headers'] = "$headercat $headerrow grade$userid"; $gradeitemdata['gradeformatted'] = $data['grade']['content']; } @@ -690,7 +690,7 @@ private function fill_table_recursive(array &$element) { GRADE_DISPLAY_TYPE_REAL, $this->rangedecimals ); - $data['range']['headers'] = "$headercat $headerrow range"; + $data['range']['headers'] = "$headercat $headerrow range$userid"; $gradeitemdata['rangeformatted'] = $data['range']['content']; $gradeitemdata['grademin'] = $gradegrade->grade_item->grademin; @@ -722,7 +722,7 @@ private function fill_table_recursive(array &$element) { GRADE_DISPLAY_TYPE_PERCENTAGE ); } - $data['percentage']['headers'] = "$headercat $headerrow percentage"; + $data['percentage']['headers'] = "$headercat $headerrow percentage$userid"; $gradeitemdata['percentageformatted'] = $data['percentage']['content']; } @@ -752,7 +752,7 @@ private function fill_table_recursive(array &$element) { GRADE_DISPLAY_TYPE_LETTER ); } - $data['lettergrade']['headers'] = "$headercat $headerrow lettergrade"; + $data['lettergrade']['headers'] = "$headercat $headerrow lettergrade$userid"; $gradeitemdata['lettergradeformatted'] = $data['lettergrade']['content']; } @@ -786,7 +786,7 @@ private function fill_table_recursive(array &$element) { $gradeitemdata['rank'] = $rank; $gradeitemdata['numusers'] = $numusers; } - $data['rank']['headers'] = "$headercat $headerrow rank"; + $data['rank']['headers'] = "$headercat $headerrow rank$userid"; } // Average. @@ -800,7 +800,7 @@ private function fill_table_recursive(array &$element) { } else { $data['average']['content'] = '-'; } - $data['average']['headers'] = "$headercat $headerrow average"; + $data['average']['headers'] = "$headercat $headerrow average$userid"; } // Feedback. @@ -834,13 +834,13 @@ private function fill_table_recursive(array &$element) { ['context' => $gradegrade->get_context()]); $gradeitemdata['feedback'] = $gradegrade->feedback; } - $data['feedback']['headers'] = "$headercat $headerrow feedback"; + $data['feedback']['headers'] = "$headercat $headerrow feedback$userid"; } // Contribution to the course total column. if ($this->showcontributiontocoursetotal) { $data['contributiontocoursetotal']['class'] = $class; $data['contributiontocoursetotal']['content'] = '-'; - $data['contributiontocoursetotal']['headers'] = "$headercat $headerrow contributiontocoursetotal"; + $data['contributiontocoursetotal']['headers'] = "$headercat $headerrow contributiontocoursetotal$userid"; } $this->gradeitemsdata[] = $gradeitemdata; @@ -1022,9 +1022,10 @@ public function print_table(bool $return = false) { ]; // Set the table headings. + $userid = $this->user->id; foreach ($this->tableheaders as $index => $heading) { $headingcell = new \html_table_cell($heading); - $headingcell->attributes['id'] = $this->tablecolumns[$index]; + $headingcell->attributes['id'] = $this->tablecolumns[$index] . $userid; $headingcell->attributes['class'] = "header column-{$this->tablecolumns[$index]}"; if ($index == 0) { $headingcell->colspan = $this->maxdepth; @@ -1050,6 +1051,12 @@ public function print_table(bool $return = false) { if (!is_null($content)) { $rowcell = new \html_table_cell($content); + // Grade item names and cateogry names are referenced in the `headers` attribute of table cells. + // These table cells should be set to tags. + if ($tablecolumn === 'itemname') { + $rowcell->header = true; + } + if (isset($rowdata[$tablecolumn]['class'])) { $rowcell->attributes['class'] = $rowdata[$tablecolumn]['class']; } diff --git a/grade/report/user/styles.css b/grade/report/user/styles.css index 05335c51e98d3..580ba9bcad9c1 100644 --- a/grade/report/user/styles.css +++ b/grade/report/user/styles.css @@ -67,6 +67,8 @@ .grade-report-user .user-grade .baggt, .grade-report-user .user-grade .baggb { font-weight: bold; + background-color: #f8f9fa; + border: none; } .path-grade-report-user .user-report-container, @@ -99,23 +101,30 @@ border-bottom: 1px solid #dee2e6; } -.path-grade-report-user .user-grade td.category, -.grade-report-user .user-grade td.category { +.path-grade-report-user .user-grade th.column-itemname:not(.header,.category,.baggt,.baggb), +.grade-report-user .user-grade th.column-itemname:not(.header,.category,.baggt,.baggb) { + background-color: white; + font-weight: normal; + border-bottom: 1px solid #dee2e6; +} + +.path-grade-report-user .user-grade th.category, +.grade-report-user .user-grade th.category { background-color: white; border: 1px solid #dee2e6; padding-left: 10px; font-weight: bold; } -.path-grade-report-user .user-grade td.category a[aria-expanded="true"] .expanded, -.path-grade-report-user .user-grade td.category a[aria-expanded="false"] .collapsed, -.grade-report-user .user-grade td.category a[aria-expanded="true"] .expanded, -.grade-report-user .user-grade td.category a[aria-expanded="false"] .collapsed { +.path-grade-report-user .user-grade th.category a[aria-expanded="true"] .expanded, +.path-grade-report-user .user-grade th.category a[aria-expanded="false"] .collapsed, +.grade-report-user .user-grade th.category a[aria-expanded="true"] .expanded, +.grade-report-user .user-grade th.category a[aria-expanded="false"] .collapsed { display: none; } -.path-grade-report-user .user-grade td.category a.toggle-category, -.grade-report-user .user-grade td.category a.toggle-category { +.path-grade-report-user .user-grade th.category a.toggle-category, +.grade-report-user .user-grade th.category a.toggle-category { height: 24px; width: 24px; font-size: 12px; @@ -123,8 +132,8 @@ margin-right: 3px; } -.path-grade-report-user .user-grade td.category a.toggle-category i, -.grade-report-user .user-grade td.category a.toggle-category i { +.path-grade-report-user .user-grade th.category a.toggle-category i, +.grade-report-user .user-grade th.category a.toggle-category i { font-size: 12px; width: 12px; height: 12px; @@ -173,14 +182,14 @@ } @media print { - .path-grade-report-user .user-grade td.category, - .grade-report-user .user-grade td.category { + .path-grade-report-user .user-grade th.category, + .grade-report-user .user-grade th.category { border-left: none; border-right: none; } - .path-grade-report-user .user-grade td.category a.toggle-category, - .grade-report-user .user-grade td.category a.toggle-category + .path-grade-report-user .user-grade th.category a.toggle-category, + .grade-report-user .user-grade th.category a.toggle-category .path-grade-report-user #page-footer { display: none; } diff --git a/h5p/classes/helper.php b/h5p/classes/helper.php index 002a90deb4e32..a9edf0de7cd8a 100644 --- a/h5p/classes/helper.php +++ b/h5p/classes/helper.php @@ -333,7 +333,7 @@ public static function get_core_settings(): array { // When there is a logged in user, her information will be passed to the player. It will be used for tracking. $usersettings = []; if (isloggedin()) { - $usersettings['name'] = $USER->username; + $usersettings['name'] = fullname($USER, has_capability('moodle/site:viewfullnames', $systemcontext)); $usersettings['id'] = $USER->id; } $settings = array( diff --git a/h5p/embed.php b/h5p/embed.php index 779b3256ce933..c2ff803d29210 100644 --- a/h5p/embed.php +++ b/h5p/embed.php @@ -51,7 +51,15 @@ if (empty($messages->error) && empty($messages->exception)) { // Configure page. - $PAGE->set_context($h5pplayer->get_context()); + $context = $h5pplayer->get_context(); + if ($context instanceof context_module) { + [$course, $cm] = get_course_and_cm_from_cmid($context->instanceid); + $PAGE->set_cm($cm, $course); + $PAGE->activityheader->disable(); + } else { + $PAGE->set_context($context); + } + $PAGE->set_title($h5pplayer->get_title()); $PAGE->set_heading($h5pplayer->get_title()); diff --git a/install/lang/fr/install.php b/install/lang/fr/install.php index f8e44a5cde196..fed064459fa13 100644 --- a/install/lang/fr/install.php +++ b/install/lang/fr/install.php @@ -86,6 +86,6 @@ $string['welcomep30'] = 'Cette version de {$a->installername} comprend des logiciels qui créent un environnement dans lequel Moodle va fonctionner, à savoir :'; $string['welcomep40'] = 'Ce paquet contient également Moodle {$a->moodlerelease} ({$a->moodleversion}).'; $string['welcomep50'] = 'L\'utilisation de tous les logiciels de ce paquetage est soumis à l\'acceptation de leurs licences respectives. Le paquetage complet {$a->installername} est un logiciel libre. Il est distribué sous licence GPL.'; -$string['welcomep60'] = 'Les pages suivantes vous aideront pas à pas à configurer et mettre en place Moodle sur votre ordinateur. Il vous sera possible d\'accepter les réglages par défaut ou, facultativement, de les adapter à vos propres besoins.'; +$string['welcomep60'] = 'Les pages suivantes vous aideront pas à pas à configurer et installer Moodle sur votre ordinateur. Il vous sera possible d\'accepter les réglages par défaut ou, facultativement, de les adapter à vos propres besoins.'; $string['welcomep70'] = 'Cliquer sur le bouton « Suivant » ci-dessous pour continuer l\'installation de Moodle.'; $string['wwwroot'] = 'Adresse web'; diff --git a/lang/en/admin.php b/lang/en/admin.php index 557fbac542cad..0fc14cb1189de 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -612,7 +612,7 @@ $string['errordeletingconfig'] = 'An error occurred while deleting the configuration records for plugin \'{$a}\'.'; $string['errorsetting'] = 'Could not save setting:'; $string['errorwithsettings'] = 'Some settings were not changed due to an error.'; -$string['eventshandlersinuse'] = 'The following plugins in your system are using Events 1 API deprecated handlers: \'{$a}\'. Please, update them to use Events 2 API. See https://docs.moodle.org/dev/Event_2#Event_dispatching_and_observers.'; +$string['eventshandlersinuse'] = 'The following plugins in your system are using Events 1 API deprecated handlers: \'{$a}\'. Please, update them to use Events 2 API. See https://docs.moodle.org/dev/Events_API#Event_dispatching_and_observers.'; $string['everyonewhocan'] = 'Everyone who can \'{$a}\''; $string['exceptions'] = 'exceptions'; $string['execpathnotallowed'] = 'Setting executable and local paths disabled in config.php'; diff --git a/lang/en/contentbank.php b/lang/en/contentbank.php index 55573a35fa9b1..61401feee82e0 100644 --- a/lang/en/contentbank.php +++ b/lang/en/contentbank.php @@ -26,15 +26,15 @@ $string['contentbank'] = 'Content bank'; $string['choosecontext'] = 'Choose course or category...'; $string['contentbankpreferences'] = 'Content bank preferences'; -$string['contentdeleted'] = 'The content has been deleted.'; +$string['contentdeleted'] = 'Content deleted.'; $string['contentname'] = 'Content name'; $string['contentnotdeleted'] = 'An error was encountered while trying to delete the content.'; $string['contentnotrenamed'] = 'An error was encountered while trying to rename the content.'; -$string['contentrenamed'] = 'The content has been renamed.'; +$string['contentrenamed'] = 'Content renamed.'; $string['contentsmoved'] = 'Content bank contents moved to {$a}.'; $string['contenttypenoaccess'] = 'You cannot view this {$a} instance.'; $string['contenttypenoedit'] = 'You can not edit this content'; -$string['contentvisibilitychanged'] = 'The content has been made {$a}.'; +$string['contentvisibilitychanged'] = 'Content is now {$a}.'; $string['contentvisibilitynotset'] = 'An error was encountered while trying to set the content visibility.'; $string['contextnotallowed'] = 'You are not allowed to access the content bank in this context.'; $string['emptynamenotallowed'] = 'Empty name is not allowed'; diff --git a/lang/en/license.php b/lang/en/license.php index f33e68718bd19..8b6c1cada8afd 100644 --- a/lang/en/license.php +++ b/lang/en/license.php @@ -21,18 +21,27 @@ * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - // Core licenses. $string['allrightsreserved'] = 'All rights reserved'; -$string['cc'] = 'Creative Commons'; -$string['cc-nc'] = 'Creative Commons - No Commercial'; -$string['cc-nc-nd'] = 'Creative Commons - No Commercial NoDerivs'; -$string['cc-nc-sa'] = 'Creative Commons - No Commercial ShareAlike'; -$string['cc-nd'] = 'Creative Commons - NoDerivs'; -$string['cc-sa'] = 'Creative Commons - ShareAlike'; $string['public'] = 'Public domain'; $string['unknown'] = 'Licence not specified'; +// Old cc-* 3.0 licenses that should be disabled and replaces by the 4.0 licenses from above. +$string['cc'] = 'Creative Commons - 3.0 International'; +$string['cc-nc'] = 'Creative Commons - NonCommercial 3.0 International'; +$string['cc-nc-nd'] = 'Creative Commons - NonCommercial-NoDerivatives 3.0 International'; +$string['cc-nc-sa'] = 'Creative Commons - NonCommercial-ShareAlike 3.0 International'; +$string['cc-nd'] = 'Creative Commons - NoDerivatives 3.0 International'; +$string['cc-sa'] = 'Creative Commons - ShareAlike 3.0 International'; + +// The new 4.0 licenses. +$string['cc-4.0'] = 'Creative Commons - 4.0 International'; +$string['cc-nc-4.0'] = 'Creative Commons - NonCommercial 4.0 International'; +$string['cc-nc-nd-4.0'] = 'Creative Commons - NonCommercial-NoDerivatives 4.0 International'; +$string['cc-nc-sa-4.0'] = 'Creative Commons - NonCommercial-ShareAlike 4.0 International'; +$string['cc-nd-4.0'] = 'Creative Commons - NoDerivatives 4.0 International'; +$string['cc-sa-4.0'] = 'Creative Commons - ShareAlike 4.0 International'; + // Error messages. $string['cannotdeletecore'] = 'Cannot delete a standard licence'; $string['cannotdeletelicenseinuse'] = 'Cannot delete a licence which is currently assigned to one or more files'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index b661fab537f4f..b3c6f47008840 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -964,7 +964,7 @@ This program 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 Moodle License information page for full details: https://docs.moodle.org/dev/License'; +See the Moodle License information page for full details: https://moodledev.io/general/license'; $string['gpllicense'] = 'GPL license'; $string['gpl3'] = 'Copyright (C) 1999 onwards Martin Dougiamas (https://moodle.com) @@ -972,7 +972,7 @@ This program 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 Moodle License information page for full details: https://docs.moodle.org/dev/License'; +See the Moodle License information page for full details: https://moodledev.io/general/license'; $string['grades'] = 'Grades'; $string['gradenoun'] = 'Grade'; $string['gradeverb'] = 'Grade'; diff --git a/lang/en/role.php b/lang/en/role.php index dc22fe216bfbb..2bd888bacc1f7 100644 --- a/lang/en/role.php +++ b/lang/en/role.php @@ -173,7 +173,7 @@ $string['course:changelockedcustomfields'] = 'Change locked custom fields'; $string['course:changeshortname'] = 'Change course short name'; $string['course:changesummary'] = 'Change course summary'; -$string['course:configurecustomfields'] = 'Configure custom fields'; +$string['course:configurecustomfields'] = 'Configure course custom fields'; $string['course:configuredownloadcontent'] = 'Configure download course content'; $string['course:downloadcoursecontent'] = 'Download course content'; $string['course:enrolconfig'] = 'Configure enrol instances in courses'; diff --git a/lib/accesslib.php b/lib/accesslib.php index 303872eb47dd5..aafb9bcb62410 100644 --- a/lib/accesslib.php +++ b/lib/accesslib.php @@ -135,17 +135,17 @@ */ define('CONTEXT_BLOCK', 80); -/** Capability allow management of trusts - NOT IMPLEMENTED YET - see {@link http://docs.moodle.org/dev/Hardening_new_Roles_system} */ +/** Capability allow management of trusts - NOT IMPLEMENTED YET - see {@link https://moodledev.io/docs/apis/subsystems/roles} */ define('RISK_MANAGETRUST', 0x0001); -/** Capability allows changes in system configuration - see {@link http://docs.moodle.org/dev/Hardening_new_Roles_system} */ +/** Capability allows changes in system configuration - see {@link https://moodledev.io/docs/apis/subsystems/roles} */ define('RISK_CONFIG', 0x0002); -/** Capability allows user to add scripted content - see {@link http://docs.moodle.org/dev/Hardening_new_Roles_system} */ +/** Capability allows user to add scripted content - see {@link https://moodledev.io/docs/apis/subsystems/roles} */ define('RISK_XSS', 0x0004); -/** Capability allows access to personal user information - see {@link http://docs.moodle.org/dev/Hardening_new_Roles_system} */ +/** Capability allows access to personal user information - see {@link https://moodledev.io/docs/apis/subsystems/roles} */ define('RISK_PERSONAL', 0x0008); -/** Capability allows users to add content others may see - see {@link http://docs.moodle.org/dev/Hardening_new_Roles_system} */ +/** Capability allows users to add content others may see - see {@link https://moodledev.io/docs/apis/subsystems/roles} */ define('RISK_SPAM', 0x0010); -/** capability allows mass delete of data belonging to other users - see {@link http://docs.moodle.org/dev/Hardening_new_Roles_system} */ +/** capability allows mass delete of data belonging to other users - see {@link https://moodledev.io/docs/apis/subsystems/roles} */ define('RISK_DATALOSS', 0x0020); /** rolename displays - the name as defined in the role definition, localised if name empty */ diff --git a/lib/ajax/service.php b/lib/ajax/service.php index 977b5e4a4b875..e2acda30a9bf3 100644 --- a/lib/ajax/service.php +++ b/lib/ajax/service.php @@ -80,10 +80,26 @@ $response = external_api::call_external_function($methodname, $args, true); $responses[$index] = $response; + if ($response['error']) { - // Do not process the remaining requests. $haserror = true; - break; + if (!NO_MOODLE_COOKIES) { + // If there was an error, and this HTTP request includes a Moodle cookie (and therefore a login), reject all + // subsequent changes. + // + // The reason for this is that an earlier step may be performing a dependant action. Consider the following: + // 1) Backup a thing + // 2) Reset the thing to its initial state + // 3) Restore the thing from the backup made in step 1. + // + // In the above example you do not want steps 2 and 3 to happen if step 1 fails. + // Do not process the remaining requests. + + // If the request came through service-nologin.php which does not allow any kind of login, + // then it is not possible to make changes to the DB, session, site, etc. + // For all other cases, we *MUST* stop processing subsequent requests. + break; + } } } diff --git a/lib/amd/build/sortable_list.min.js b/lib/amd/build/sortable_list.min.js index 9f2ac1467c001..bd965207e7a89 100644 --- a/lib/amd/build/sortable_list.min.js +++ b/lib/amd/build/sortable_list.min.js @@ -12,8 +12,6 @@ * Space between define and ( critical in comment but not allowed in code in order to function * correctly with Moodle's requirejs.php * - * More details: https://docs.moodle.org/dev/Sortable_list - * * For the full list of possible parameters see var defaultParameters below. * * The following jQuery events are fired: diff --git a/lib/amd/build/sortable_list.min.js.map b/lib/amd/build/sortable_list.min.js.map index 454f008be4976..b55723897969d 100644 --- a/lib/amd/build/sortable_list.min.js.map +++ b/lib/amd/build/sortable_list.min.js.map @@ -1 +1 @@ -{"version":3,"file":"sortable_list.min.js","sources":["../src/sortable_list.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle list items drag and drop\n *\n * Example of usage:\n *\n * Create a list (for example `