diff --git a/.github/workflows/onebyone.yml b/.github/workflows/onebyone.yml index a29e1e490a909..2681ea6ebd1b2 100644 --- a/.github/workflows/onebyone.yml +++ b/.github/workflows/onebyone.yml @@ -51,7 +51,7 @@ jobs: chunk=$(((($count % $chunks)) + 1)) echo "$testname $testfile" >> ./chunk_$chunk.txt done < <(grep "function test_" "${testfile}" | sed -r "s/^.*function (test_[a-zA-Z0-9_]+).*/\1/") - done < <(find . -name "*_test.php") + done < <(find . -name "*_test.php" -not -path "*/fixtures/*") # Generate the matrix to run tests. echo "matrix=$(ls -1 chunk_*.txt | jq -R -s -c 'split("\n")[:-1]')" >> $GITHUB_OUTPUT echo "$count individual tests collected in $chunks files" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index e52c823c2991f..7e24b93f167e3 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -56,6 +56,14 @@ jobs: image: redis ports: - 6379:6379 + postgres: + image: ${{ matrix.db == 'pgsql' && 'postgres:14' || '' }} + env: + POSTGRES_DB: test + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - 5432:5432 strategy: fail-fast: false matrix: @@ -71,27 +79,20 @@ jobs: db: pgsql steps: - - name: Setting up DB mysql + - name: Run MySQL Server if: ${{ matrix.db == 'mysqli' }} - uses: moodlehq/mysql-action@v1 - with: - collation server: utf8mb4_bin - mysql version: 8.4 - mysql database: test - mysql user: test - mysql password: test - use tmpfs: true - tmpfs size: '1024M' - extra conf: --skip-log-bin - - - name: Setting up DB pgsql - if: ${{ matrix.db == 'pgsql' }} - uses: m4nu56/postgresql-action@v1 - with: - postgresql version: 14 - postgresql db: test - postgresql user: test - postgresql password: test + run: | + docker run --rm \ + -e MYSQL_DATABASE=test \ + -e MYSQL_USER=test \ + -e MYSQL_PASSWORD=test \ + -e MYSQL_ROOT_PASSWORD=test \ + -p 3306:3306 \ + -d \ + --tmpfs /var/lib/mysql:rw,noexec,nosuid,size=1024M \ + mysql:8.4 \ + --skip-log-bin \ + --collation-server=utf8mb4_bin - name: Configuring git vars uses: rlespinasse/github-slug-action@v4 diff --git a/.grunt/notes/src/create.mjs b/.grunt/notes/src/create.mjs index 70c41532db87d..f8e3cb4a10742 100644 --- a/.grunt/notes/src/create.mjs +++ b/.grunt/notes/src/create.mjs @@ -18,7 +18,7 @@ import inquirer from 'inquirer'; import chalk from 'chalk'; import { createNote } from './note.mjs'; -import { getInitialValues } from './helpers.mjs'; +import { getInitialValues, getIssueNumberFromBranchName } from './helpers.mjs'; import * as Prompts from './prompts.mjs'; import logger from './logger.mjs'; @@ -29,7 +29,7 @@ export default async (options) => { // Fetch information. const messages = []; const { issueNumber } = await inquirer.prompt([ - Prompts.getIssuePrompt(), + Prompts.getIssuePrompt(getIssueNumberFromBranchName()), ], initialValues); let selection = {}; diff --git a/.grunt/notes/src/helpers.mjs b/.grunt/notes/src/helpers.mjs index 87d741e732a6b..9f6e90c8008eb 100644 --- a/.grunt/notes/src/helpers.mjs +++ b/.grunt/notes/src/helpers.mjs @@ -14,6 +14,7 @@ // along with Moodle. If not, see . import chalk from 'chalk'; +import branchName from 'current-git-branch'; import { isStandardComponent, isCommunityComponent, rewritePlugintypeAsSubsystem } from './components.mjs'; import { isValidNoteName } from './noteTypes.mjs'; import logger from './logger.mjs'; @@ -59,6 +60,19 @@ export const formatIssueNumber = (input) => { return input; }; +/** + * Get the issue number, based off current git branch name + * + * @returns {string|null} + */ +export const getIssueNumberFromBranchName = () => { + const issueNumber = String(branchName()).match(/^[a-zA-Z]*-\d+/); + if (issueNumber) { + return formatIssueNumber(issueNumber[0]); + } + return null; +}; + /** * Validate a component. * diff --git a/.grunt/tasks/watch.js b/.grunt/tasks/watch.js index 25c64876c7143..607df77f688a6 100644 --- a/.grunt/tasks/watch.js +++ b/.grunt/tasks/watch.js @@ -49,6 +49,10 @@ module.exports = grunt => { // Spawn the task in a child process so that it doesn't kill this one // if it failed. + var args = [task, filesOption]; + if (grunt.option('force')) { + args.push('--force'); + } grunt.util.spawn( { // Spawn with the grunt bin. @@ -58,7 +62,7 @@ module.exports = grunt => { cwd: grunt.moodleEnv.fullRunDir, stdio: 'inherit' }, - args: [task, filesOption] + args: args }, function(err, res, code) { if (code !== 0) { diff --git a/.upgradenotes/MDL-81514-2024061009100437.yml b/.upgradenotes/MDL-81514-2024061009100437.yml deleted file mode 100644 index adbcd912c012f..0000000000000 --- a/.upgradenotes/MDL-81514-2024061009100437.yml +++ /dev/null @@ -1,13 +0,0 @@ -issueNumber: MDL-81514 -notes: - core_group: - - message: > - `groups_print_activity_menu()` and `groups_get_activity_group()` now - include an additional `$participationonly` parameter, which is true by - default. This can be set false when we want the user to be able to - select a non-participation group within an activity, for example if a - teacher wants to filter assignment submissions by non-participation - groups. It should never be used when the menu is displayed to students, - as this may allow them to participate using non-participation groups. - Non-participation groups are labeled as such. - type: improved diff --git a/.upgradenotes/MDL-85975-2025092314370040.yml b/.upgradenotes/MDL-85975-2025092314370040.yml deleted file mode 100644 index 263b753aca2d0..0000000000000 --- a/.upgradenotes/MDL-85975-2025092314370040.yml +++ /dev/null @@ -1,17 +0,0 @@ -issueNumber: MDL-85975 -notes: - core: - - message: > - `restore_qtype_plugin::unset_excluded_fields` now returns the modified - questiondata structure, - - in order to support structures that contain arrays. - - If your qtype plugin overrides - `restore_qtype_plugin::remove_excluded_question_data` without - - calling the parent method, you may need to modify your overridden method - to use the returned - - value. - type: fixed diff --git a/.upgradenotes/MDL-86332-2025081922154697.yml b/.upgradenotes/MDL-86332-2025081922154697.yml deleted file mode 100644 index e580c779d36ab..0000000000000 --- a/.upgradenotes/MDL-86332-2025081922154697.yml +++ /dev/null @@ -1,8 +0,0 @@ -issueNumber: MDL-86332 -notes: - core_completion: - - message: >- - The `completion_info::clear_criteria` method takes an optional - `$removetypecriteria` to determine whether to remove course type - criteria from other courses that refer to the current course - type: changed diff --git a/.upgradenotes/MDL-87079-2025110401192093.yml b/.upgradenotes/MDL-87079-2025110401192093.yml deleted file mode 100644 index 1496707ae1444..0000000000000 --- a/.upgradenotes/MDL-87079-2025110401192093.yml +++ /dev/null @@ -1,5 +0,0 @@ -issueNumber: MDL-87079 -notes: - core: - - message: When responding to pcntl signals, call existing signal handlers. - type: fixed diff --git a/.upgradenotes/MDL-87107-2025110505340589.yml b/.upgradenotes/MDL-87107-2025110505340589.yml deleted file mode 100644 index f67543a0e2dda..0000000000000 --- a/.upgradenotes/MDL-87107-2025110505340589.yml +++ /dev/null @@ -1,5 +0,0 @@ -issueNumber: MDL-87107 -notes: - core: - - message: The Hook Manager now uses localcache instead of caching via MUC. - type: changed diff --git a/README.md b/README.md index 249fc218e099f..5a304b349ffdb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Moodle also offers hosting through both [MoodleCloud][11], and our [partner netw ## License -Moodle is provided freely as open source software, under version 3 of the GNU General Public License. For more information on our license see +Moodle is provided freely as open source software, under version 3 of the GNU General Public License. See our [license page][12] for more information. [1]: https://moodle.org [2]: https://moodle.com diff --git a/UPGRADING.md b/UPGRADING.md index c027f9660f607..db076bc4d8c77 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -6,10 +6,132 @@ More detailed information on key changes can be found in the [Developer update n The format of this change log follows the advice given at [Keep a CHANGELOG](https://keepachangelog.com). -## 5.0.3+ +## 5.0.7 ### core +#### Added + +- "grunt watch" now accepts a force flag. Run "grunt watch -f" or "grunt watch --force" to prevent grunt from cancelling builds when errors occur. This is especially useful during development, because js build files will be built even if, for example, jslint errors are still present in the files. + + For more information see [MDL-86839](https://tracker.moodle.org/browse/MDL-86839) +- When creating upgrade notes, the issue number will be inferred from the current Git branch name by default + + For more information see [MDL-87100](https://tracker.moodle.org/browse/MDL-87100) +- There is a new Behat `toast_message` named selector to more easily assert the presence of Toast messages on the page + + For more information see [MDL-87443](https://tracker.moodle.org/browse/MDL-87443) +- The `core/toast` JS module now accepts a `visuallyHidden` configuration parameter to render visually hidden toast messages for screen reader users. + + For more information see [MDL-87993](https://tracker.moodle.org/browse/MDL-87993) + +### core_files + +#### Added + +- A new method called `removeopt()` has been created in the `curl` class to allow users to remove options previously set with `setopt()`. + + For more information see [MDL-87822](https://tracker.moodle.org/browse/MDL-87822) +- User can pass `'CURLOPT_USERPWD' => false` to the `$options` array for the `put()` method of `curl` to remove the `CURLOPT_USERPWD` option from the request. + + For more information see [MDL-87822](https://tracker.moodle.org/browse/MDL-87822) + +### core_question + +#### Added + +- During restore of a question_set_reference, mapping of IDs in the filtercondition is now delegated to qbank plugins. If your qbank plugin defines a filter condition that uses database IDs, add an override of `restore_filtercondition()` to the `condition` class, which checks the condition's data and replaces the IDs with mapped values if required. See `qbank_managecategories\category_condition` for an example. + + For more information see [MDL-86524](https://tracker.moodle.org/browse/MDL-86524) + +### core_user + +#### Added + +- Added new optional parameter `userid` to the `user_remove_user_device` function. + + For more information see [MDL-87795](https://tracker.moodle.org/browse/MDL-87795) + +### aiprovider_openai + +#### Added + +- A new `aiprovider_openai\aimodel\openai_image_base` interface has been added. Image generation model classes must now implement this interface to declare their `response_format`, `output_format`, size, and quality mappings. Existing custom model classes that handle image generation should implement this interface to ensure correct API parameters are sent. + + For more information see [MDL-85352](https://tracker.moodle.org/browse/MDL-85352) +- A new `gptimage1` model class has been added to support gpt-image-1.5. + This model uses `output_format=png` instead of `response_format`, and maps Moodle quality values to the values expected by the API: 'standard' maps to 'medium' and 'hd' maps to 'high'. + + For more information see [MDL-85352](https://tracker.moodle.org/browse/MDL-85352) + +#### Changed + +- The `dalle3` model class now implements `openai_image_base` and switches from returning a URL to returning `response_format=b64_json`. + The image is now decoded directly from the API response instead of being downloaded via a second HTTP request. Size and quality logic has been moved into the model class. + + For more information see [MDL-85352](https://tracker.moodle.org/browse/MDL-85352) + +### auth_db + +#### Deprecated + +- The `ext_addslashes()` method has been deprecated from `auth_plugin_db`, because external database queries now use parameterized statements instead. As a result, the `sybasequoting` setting has also been removed, since it was only ever used by that method. + + For more information see [MDL-88138](https://tracker.moodle.org/browse/MDL-88138) + +### block_html + +#### Changed + +- Treat Dashboard (pagetype 'my-index') as trusted in web services so get_content_for_external preserves embedded HTML (e.g. iframes) on user Dashboard. + + For more information see [MDL-85322](https://tracker.moodle.org/browse/MDL-85322) + +### enrol_manual + +#### Removed + +- The unused parameter 'roleid' has been removed from the external function `unenrol_users()` + + For more information see [MDL-51152](https://tracker.moodle.org/browse/MDL-51152) + +### tool_behat + +#### Added + +- The `behat_session_trait::ensure_element_[does_not_]exists(...)` methods now accept optional `$container` parameter to define the parent node to look within + + For more information see [MDL-75067](https://tracker.moodle.org/browse/MDL-75067) + +## 5.0.5 + +### core + +#### Changed + +- `\core\output\core_renderer::confirm()`'s `$displayoptions` parameter now also accepts a `headinglevel` option that developers can use to specify the heading level of the confirmation's heading. If not specified, the confirmation heading will be rendered in an `h4` tag. + + For more information see [MDL-87694](https://tracker.moodle.org/browse/MDL-87694) + +### core_question + +#### Fixed + +- In order to prevent re-use of question version numbers after a version is deleted, the `nextversion` column was added to `question_bank_entries`. This serves as a counter incremented each time a version is created. + Do not query this field directly. Instead use `core_question\versions::get_next_version()` to read the value, which will initialise it based on the existing versions if it is not set yet. By default, it will increment the version number automatically, unless you pass `increment: false`. Because of this, it is advisable to call it inside a transaction, that is only committed after the version number is used in a `question_versions` record. + + For more information see [MDL-86798](https://tracker.moodle.org/browse/MDL-86798) + +## 5.0.4 + +### core + +#### Added + +- Added clean_string() that prevents double escaping in Mustache templates + + For more information see [MDL-87066](https://tracker.moodle.org/browse/MDL-87066) + #### Changed - The Hook Manager now uses localcache instead of caching via MUC. @@ -37,6 +159,14 @@ The format of this change log follows the advice given at [Keep a CHANGELOG](htt For more information see [MDL-86332](https://tracker.moodle.org/browse/MDL-86332) +### core_course + +#### Added + +- The external function `core_course_get_course_contents` now includes the `candisplay` property for each returned module. If this is false, the module should not be displayed on the course page (for example, for question banks). + + For more information see [MDL-85405](https://tracker.moodle.org/browse/MDL-85405) + ### core_group #### Added @@ -45,6 +175,14 @@ The format of this change log follows the advice given at [Keep a CHANGELOG](htt For more information see [MDL-81514](https://tracker.moodle.org/browse/MDL-81514) +### mod_glossary + +#### Added + +- Function mod_glossary_rating_can_see_item_ratings is now implemented for checking permissions to view ratings. + + For more information see [MDL-86960](https://tracker.moodle.org/browse/MDL-86960) + ## 5.0.3 ### core diff --git a/admin/category.php b/admin/category.php index 803367bc772a7..0bdfbe0c11de5 100644 --- a/admin/category.php +++ b/admin/category.php @@ -119,8 +119,8 @@ $outputhtml .= html_writer::tag('div', '', array('class' => 'clearer')); $outputhtml .= $setting->output_html($data); if ($childpage->has_dependencies()) { - $opts = ['dependencies' => $childpage->get_dependencies_for_javascript()]; - $PAGE->requires->js_call_amd('core/showhidesettings', 'init', [$opts]); + $context = ['dependencies' => json_encode($childpage->get_dependencies_for_javascript())]; + echo $OUTPUT->render_from_template('core_admin/settings_showhide', $context); } } $outputhtml .= html_writer::end_tag('fieldset'); diff --git a/admin/classes/admin/admin_setting_notification.php b/admin/classes/admin/admin_setting_notification.php new file mode 100644 index 0000000000000..de4b21bf8df59 --- /dev/null +++ b/admin/classes/admin/admin_setting_notification.php @@ -0,0 +1,74 @@ +. + +namespace core_admin\admin; + +use admin_setting; + +/** + * Render a notification as part of other admin settings. + * + * @package core_admin + * @subpackage admin + * @copyright 2025 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class admin_setting_notification extends admin_setting { + /** + * Constructor. + * + * @param string $name The name of the setting. + * @param string $notification The notification to display. + * @param string $type The type of notification. + * @param bool $cancelable Whether the notification can be canceled. + */ + public function __construct( + string $name, + /** @var string The notification to display. */ + protected string $notification, + /** @var string The type of notification. */ + protected string $type = 'info', + /** @var bool Whether the notification can be canceled. */ + protected bool $cancelable = false + ) { + $this->nosave = true; + + parent::__construct($name, '', '', ''); + } + + #[\Override] + public function get_setting(): bool { + return true; + } + + #[\Override] + public function get_defaultsetting(): bool { + return true; + } + + #[\Override] + public function write_setting($data): string { + // Do not write any setting. + return ''; + } + + #[\Override] + public function output_html($data, $query = ''): string { + global $OUTPUT; + + return $OUTPUT->notification($this->notification, $this->type, $this->cancelable); + } +} diff --git a/admin/cli/scheduled_task.php b/admin/cli/scheduled_task.php index d75b4f8e0fbdb..940f6df22bd38 100644 --- a/admin/cli/scheduled_task.php +++ b/admin/cli/scheduled_task.php @@ -125,11 +125,6 @@ exit(0); } -if (moodle_needs_upgrading()) { - mtrace("Moodle upgrade pending, cannot manage tasks."); - exit(1); -} - if ($disable = $options['disable']) { if (!$task = \core\task\manager::get_scheduled_task($disable)) { mtrace("Task '$disable' not found"); @@ -157,6 +152,11 @@ exit(1); } } else if ($execute = $options['execute']) { + if (moodle_needs_upgrading()) { + mtrace("Moodle upgrade pending, cannot execute tasks."); + exit(1); + } + if (!$task = \core\task\manager::get_scheduled_task($execute)) { mtrace("Task '$execute' not found"); exit(1); diff --git a/admin/environment.xml b/admin/environment.xml index 7016851ee14c8..41a84be35f8ce 100644 --- a/admin/environment.xml +++ b/admin/environment.xml @@ -5105,4 +5105,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/index.php b/admin/index.php index 90d5307ecb762..e6815e03fce49 100644 --- a/admin/index.php +++ b/admin/index.php @@ -941,9 +941,27 @@ $output = $PAGE->get_renderer('core', 'admin'); -echo $output->admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed, $cronoverdue, $dbproblems, - $maintenancemode, $availableupdates, $availableupdatesfetch, $buggyiconvnomb, - $registered, $cachewarnings, $eventshandlers, $themedesignermode, $devlibdir, - $mobileconfigured, $overridetossl, $invalidforgottenpasswordurl, $croninfrequent, - $showcampaigncontent, $showfeedbackencouragement, $servicesandsupportcontent, - $xmlrpcwarning); +echo $output->admin_notifications_page( + $maturity, + $insecuredataroot, + $errorsdisplayed, + $cronoverdue, + $dbproblems, + $maintenancemode, + $availableupdates, + $availableupdatesfetch, + $buggyiconvnomb, + $registered, + $cachewarnings, + $eventshandlers, + $themedesignermode, + $devlibdir, + $mobileconfigured, + $overridetossl, + $invalidforgottenpasswordurl, + $croninfrequent, + $showcampaigncontent, + $showfeedbackencouragement, + $servicesandsupportcontent, + $xmlrpcwarning +); diff --git a/admin/mnet/peers.php b/admin/mnet/peers.php index 994d5a77c88db..b8492f5c2888f 100644 --- a/admin/mnet/peers.php +++ b/admin/mnet/peers.php @@ -37,12 +37,6 @@ $hostid = optional_param('hostid', 0, PARAM_INT); $updra = optional_param('updateregisterall', 0, PARAM_INT); -// first process the register all hosts setting if required -if (!empty($updra)) { - set_config('mnet_register_allhosts', optional_param('registerallhosts', 0, PARAM_INT)); - redirect(new moodle_url('/admin/mnet/peers.php'), get_string('changessaved')); -} - $adminsection = 'mnetpeers'; if ($hostid && $DB->get_field('mnet_host', 'deleted', array('id' => $hostid)) != 1) { $adminsection = 'mnetpeer' . $hostid; @@ -51,6 +45,11 @@ $PAGE->set_url('/admin/mnet/peers.php'); admin_externalpage_setup($adminsection); +if (!empty($updra) && confirm_sesskey()) { + set_config('mnet_register_allhosts', optional_param('registerallhosts', 0, PARAM_INT)); + redirect(new moodle_url('/admin/mnet/peers.php'), get_string('changessaved')); +} + if (!extension_loaded('openssl')) { throw new \moodle_exception('requiresopenssl', 'mnet'); } @@ -189,7 +188,7 @@ $registerrow = ''; $registerstr = ''; -$registerurl = new moodle_url('/admin/mnet/peers.php', array('updateregisterall' => 1)); +$registerurl = new moodle_url('/admin/mnet/peers.php', array('updateregisterall' => 1, 'sesskey' => sesskey())); if (!empty($CFG->mnet_register_allhosts)) { $registerrow = get_string('registerhostson', 'mnet'); $registerurl->param('registerallhosts', 0); diff --git a/admin/qbehaviours.php b/admin/qbehaviours.php index d047669b92201..b4446fcff5b62 100644 --- a/admin/qbehaviours.php +++ b/admin/qbehaviours.php @@ -144,11 +144,13 @@ // Set up the table. $table = new flexible_table('qbehaviouradmintable'); $table->define_baseurl($thispageurl); -$table->define_columns(array('behaviour', 'numqas', 'version', 'requires', - 'available', 'uninstall')); -$table->define_headers(array(get_string('behaviour', 'question'), get_string('numqas', 'question'), +$table->define_columns(['behaviour', 'version', 'requires', + 'numqas', 'available', 'settings', 'uninstall']); +$table->define_headers([get_string('behaviour', 'question'), get_string('version'), get_string('requires', 'admin'), - get_string('availableq', 'question'), get_string('uninstallplugin', 'core_admin'))); + get_string('numqas', 'question'), + get_string('availableq', 'question'), get_string('settings'), + get_string('uninstallplugin', 'core_admin')]); $table->set_attribute('id', 'qbehaviours'); $table->set_attribute('class', 'generaltable admintable'); $table->setup(); @@ -160,9 +162,6 @@ // Question icon and name. $row[] = $behaviourname; - // Count - $row[] = $counts[$behaviour]; - // Question version number. $version = get_config('qbehaviour_' . $behaviour, 'version'); if ($version) { @@ -184,6 +183,9 @@ $row[] = ''; } + // Count. + $row[] = $counts[$behaviour]; + // Are people allowed to select this behaviour? $rowclass = ''; if ($archetypal[$behaviour]) { @@ -201,6 +203,14 @@ $icons .= question_behaviour_icon_html('down', $behaviour, 't/down', get_string('down'), null); $row[] = $icons; + // Settings link, if available. + $settings = admin_get_root()->locate('qbehavioursetting_' . $behaviour); + if ($settings instanceof \core_admin\local\settings\linkable_settings_page) { + $row[] = html_writer::link($settings->get_settings_page_url(), get_string('settings')); + } else { + $row[] = ''; + } + // Delete link, if available. if ($needed[$behaviour]) { $row[] = ''; diff --git a/admin/renderer.php b/admin/renderer.php index 93c4943db9c85..11c52fcbc4ff0 100644 --- a/admin/renderer.php +++ b/admin/renderer.php @@ -282,13 +282,30 @@ public function upgrade_confirm_abort_install_page(array $abortable, moodle_url * * @return string HTML to output. */ - public function admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed, - $cronoverdue, $dbproblems, $maintenancemode, $availableupdates, $availableupdatesfetch, - $buggyiconvnomb, $registered, array $cachewarnings = array(), $eventshandlers = 0, - $themedesignermode = false, $devlibdir = false, $mobileconfigured = false, - $overridetossl = false, $invalidforgottenpasswordurl = false, $croninfrequent = false, - $showcampaigncontent = false, bool $showfeedbackencouragement = false, bool $showservicesandsupport = false, - $xmlrpcwarning = '') { + public function admin_notifications_page( + $maturity, + $insecuredataroot, + $errorsdisplayed, + $cronoverdue, + $dbproblems, + $maintenancemode, + $availableupdates, + $availableupdatesfetch, + $buggyiconvnomb, + $registered, + array $cachewarnings = [], + $eventshandlers = 0, + $themedesignermode = false, + $devlibdir = false, + $mobileconfigured = false, + $overridetossl = false, + $invalidforgottenpasswordurl = false, + $croninfrequent = false, + $showcampaigncontent = false, + bool $showfeedbackencouragement = false, + bool $showservicesandsupport = false, + $xmlrpcwarning = '' + ) { global $CFG; $output = ''; @@ -313,6 +330,8 @@ public function admin_notifications_page($maturity, $insecuredataroot, $errorsdi $output .= $this->mobile_configuration_warning($mobileconfigured); $output .= $this->forgotten_password_url_warning($invalidforgottenpasswordurl); $output .= $this->mnet_deprecation_warning($xmlrpcwarning); + $output .= $this->moodlenet_removal_warning(); + $output .= $this->marketplace_integration_notice(); $output .= $this->userfeedback_encouragement($showfeedbackencouragement); $output .= $this->services_and_support_content($showservicesandsupport); $output .= $this->campaign_content($showcampaigncontent); @@ -2326,6 +2345,33 @@ protected function mnet_deprecation_warning($xmlrpcwarning) { return $this->warning($xmlrpcwarning); } + /** + * Display a warning about the removal of MoodleNet integration. + * + * @return string HTML to output. + */ + protected function moodlenet_removal_warning(): string { + $moodlenetenabled = get_config('tool_moodlenet', 'enablemoodlenet'); + if (!empty($moodlenetenabled)) { + $moodlenetwarning = get_string('moodlenetremovalwarning', 'admin'); + return $this->warning($moodlenetwarning); + } + + return ''; + } + + /** + * Display a notice about Moodle Marketplace integration. + * + * @return string HTML to output. + */ + protected function marketplace_integration_notice(): string { + $installer = tool_installaddon_installer::instance(); + $url = $installer->get_marketplace_url(); + $notice = get_string('marketplaceavailablenotice', 'admin', $url->out()); + return $this->warning($notice, 'info'); + } + /** * Renders the theme selector list. * diff --git a/admin/search.php b/admin/search.php index 2d1920d5ec186..d3e8c3f23d893 100644 --- a/admin/search.php +++ b/admin/search.php @@ -32,7 +32,6 @@ $adminroot->search = $query; // So we can reference it in search boxes later in this invocation $statusmsg = ''; $errormsg = ''; -$focus = ''; // now we'll deal with the case that the admin has submitted the form with changed settings if ($data = data_submitted() and confirm_sesskey() and isset($data->action) and $data->action == 'save-settings') { @@ -41,7 +40,7 @@ if (!empty($adminroot->errors)) { $errormsg = get_string('errorwithsettings', 'admin'); $firsterror = reset($adminroot->errors); - $focus = $firsterror->id; + $PAGE->set_focuscontrol($firsterror->id); } else { // No errors. Did we change any setting? If so, then redirect with success. if ($count) { @@ -55,7 +54,7 @@ // and finally, if we get here, then there are matching settings and we have to print a form // to modify them -echo $OUTPUT->header($focus); +echo $OUTPUT->header(); // Display a warning if site is not registered. if (empty($query)) { diff --git a/admin/settings.php b/admin/settings.php index 48171745b3a14..7ff266d8f0bda 100644 --- a/admin/settings.php +++ b/admin/settings.php @@ -72,6 +72,7 @@ } else { $errormsg = get_string('errorwithsettings', 'admin'); $firsterror = reset($adminroot->errors); + $PAGE->set_focuscontrol($firsterror->id); } $settingspage = $adminroot->locate($section, true); } @@ -165,10 +166,8 @@ $PAGE->requires->js_call_amd('core_form/changechecker', 'watchFormById', ['adminsettings']); if ($settingspage->has_dependencies()) { - $opts = [ - 'dependencies' => $settingspage->get_dependencies_for_javascript() - ]; - $PAGE->requires->js_call_amd('core/showhidesettings', 'init', [$opts]); + $context = ['dependencies' => json_encode($settingspage->get_dependencies_for_javascript())]; + echo $OUTPUT->render_from_template('core_admin/settings_showhide', $context); } echo $OUTPUT->footer(); diff --git a/admin/settings/ai.php b/admin/settings/ai.php index ecb00c8c09328..5a27bab52de83 100644 --- a/admin/settings/ai.php +++ b/admin/settings/ai.php @@ -32,19 +32,29 @@ $providers->add(new admin_setting_heading('availableproviders', get_string('availableproviders', 'core_ai'), get_string('availableproviders_desc', 'core_ai'))); - // Add call to action to add a new provider. - $providers->add(new \core_admin\admin\admin_setting_template_render( - name: 'addnewprovider', - templatename: 'core_ai/admin_add_provider', - context: ['addnewproviderurl' => new moodle_url('/ai/configure.php')] - )); - $providers->add(new \core_ai\admin\admin_setting_provider_manager( + if (!empty(core_plugin_manager::instance()->get_plugins_of_type("aiprovider"))) { + // Add call to action to add a new provider. + $providers->add(new \core_admin\admin\admin_setting_template_render( + name: 'addnewprovider', + templatename: 'core_ai/admin_add_provider', + context: ['addnewproviderurl' => new moodle_url('/ai/configure.php')] + )); + + $providers->add(new \core_ai\admin\admin_setting_provider_manager( 'aiprovider', \core_ai\table\aiprovider_management_table::class, 'manageaiproviders', new lang_string('manageaiproviders', 'core_ai'), - )); + )); + } else { + $providers->add(new \core_admin\admin\admin_setting_notification( + name:'noproviderplugins', + notification: get_string('noproviderplugins', 'core_ai'), + type: 'danger' + )); + } + $ADMIN->add('ai', $providers); // Add settings page for AI placement settings. diff --git a/admin/settings/plugins.php b/admin/settings/plugins.php index dd413bf7145c5..1bad7aa0e1d1b 100644 --- a/admin/settings/plugins.php +++ b/admin/settings/plugins.php @@ -573,6 +573,14 @@ /** @var \core\plugininfo\qtype $plugin */ $plugin->load_settings($ADMIN, 'qtypesettings', $hassiteconfig); } + + // Settings for particular question behaviours. + $plugins = core_plugin_manager::instance()->get_plugins_of_type('qbehaviour'); + core_collator::asort_objects_by_property($plugins, 'displayname'); + foreach ($plugins as $plugin) { + /** @var \core\plugininfo\qtype $plugin */ + $plugin->load_settings($ADMIN, 'qbehavioursettings', $hassiteconfig); + } } // Plagiarism plugin settings diff --git a/admin/tasklogs.php b/admin/tasklogs.php index 526ffa4686fa1..0bc5cf9447caa 100644 --- a/admin/tasklogs.php +++ b/admin/tasklogs.php @@ -29,7 +29,14 @@ use core_admin\reportbuilder\local\systemreports\task_logs; use core_reportbuilder\system_report_factory; -$PAGE->set_url(new \moodle_url('/admin/tasklogs.php')); +$logid = optional_param('logid', null, PARAM_INT); +$download = optional_param('download', false, PARAM_BOOL); +$filter = optional_param('filter', null, PARAM_TEXT); + +$PAGE->set_url(new \moodle_url('/admin/tasklogs.php', array_filter([ + 'logid' => $logid, + 'filter' => $filter, +]))); $PAGE->set_context(context_system::instance()); $PAGE->set_pagelayout('admin'); $strheading = get_string('tasklogs', 'admin'); @@ -37,11 +44,6 @@ $PAGE->set_heading($strheading); admin_externalpage_setup('tasklogs'); - -$logid = optional_param('logid', null, PARAM_INT); -$download = optional_param('download', false, PARAM_BOOL); -$filter = optional_param('filter', null, PARAM_TEXT); - if (null !== $logid) { // Raise memory limit in case the log is large. raise_memory_limit(MEMORY_HUGE); diff --git a/admin/templates/settings_showhide.mustache b/admin/templates/settings_showhide.mustache new file mode 100644 index 0000000000000..e02d5b14ad46e --- /dev/null +++ b/admin/templates/settings_showhide.mustache @@ -0,0 +1,51 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_admin/settings_showhide + + Passes the necessary data to show/hide dependant settings. + + Context variables required for this template: + * dependencies - JSON-serialized data structure describing the dependencies between settings + + Example context (json): + { + "dependencies": { + "s__setting1":{ + "eq":[ + [ + "s__setting0" + ] + ] + }, + "s__setting2":{ + "eq":[ + [ + "s__setting1" + ] + ] + } + } + } +}} + + +{{#js}} + require(['core/showhidesettings'], function(ShowHideSettings) { + ShowHideSettings.init("settings-showhide-{{uniqid}}"); + }); +{{/js}} diff --git a/admin/tests/behat/outgoing_mail.feature b/admin/tests/behat/outgoing_mail.feature index 822c4c6c94c07..73e99a4f63dce 100644 --- a/admin/tests/behat/outgoing_mail.feature +++ b/admin/tests/behat/outgoing_mail.feature @@ -7,13 +7,13 @@ Feature: Outgoing mail configuration Background: Given I log in as "admin" - Scenario: SMTP Auth Type without OAuth 2 service setup yet + Scenario: SMTP auth type without OAuth 2 service setup yet Given I navigate to "Server > Email > Outgoing mail configuration" in site administration - And I should not see "XOAUTH2" in the "SMTP Auth Type" "select" - And I should see "LOGIN" in the "SMTP Auth Type" "select" - And I should see "PLAIN" in the "SMTP Auth Type" "select" + And I should not see "XOAUTH2" in the "SMTP auth type" "select" + And I should see "LOGIN" in the "SMTP auth type" "select" + And I should see "PLAIN" in the "SMTP auth type" "select" - Scenario: SMTP Auth Type with OAuth 2 service setup + Scenario: SMTP auth type with OAuth 2 service setup Given I navigate to "Server > OAuth 2 services" in site administration And I press "Google" And I should see "Create new service: Google" @@ -23,7 +23,7 @@ Feature: Outgoing mail configuration | Client secret | supersecret | And I press "Save changes" When I navigate to "Server > Email > Outgoing mail configuration" in site administration - Then I should see "XOAUTH2" in the "SMTP Auth Type" "select" - And I should see "LOGIN" in the "SMTP Auth Type" "select" - And I should see "PLAIN" in the "SMTP Auth Type" "select" + Then I should see "XOAUTH2" in the "SMTP auth type" "select" + And I should see "LOGIN" in the "SMTP auth type" "select" + And I should see "PLAIN" in the "SMTP auth type" "select" And I should see "Testing service" in the "OAuth 2 service" "select" diff --git a/admin/tool/behat/UPGRADING.md b/admin/tool/behat/UPGRADING.md index 4d1494b9752b0..3db73a6dc8cc3 100644 --- a/admin/tool/behat/UPGRADING.md +++ b/admin/tool/behat/UPGRADING.md @@ -1,5 +1,13 @@ # tool_behat Upgrade notes +## 5.0.7 + +### Added + +- The `behat_session_trait::ensure_element_[does_not_]exists(...)` methods now accept optional `$container` parameter to define the parent node to look within + + For more information see [MDL-75067](https://tracker.moodle.org/browse/MDL-75067) + ## 5.0 ### Added diff --git a/admin/tool/behat/cli/run.php b/admin/tool/behat/cli/run.php index e0e33016f35de..d38927d55180c 100644 --- a/admin/tool/behat/cli/run.php +++ b/admin/tool/behat/cli/run.php @@ -224,6 +224,11 @@ $cmds['singlerun'] = $runtestscommand; echo "Running single behat site:" . PHP_EOL; + // The inner PHP process is not marked as having an interactive terminal even if it's passed + // through from this one which does, so we need to pass it through as an environment variable. + if (function_exists('posix_isatty') && posix_isatty(STDOUT)) { + putenv('MOODLE_BEHAT_RUNNING_IN_TTY=1'); + } passthru("php $runtestscommand", $status); $exitcodes['singlerun'] = $status; chdir($cwd); @@ -525,4 +530,3 @@ function get_status_lines_from_run_op(Symfony\Component\Process\Process $process return $statusstr; } - diff --git a/admin/tool/dataprivacy/tests/behat/user_data_request.feature b/admin/tool/dataprivacy/tests/behat/user_data_request.feature new file mode 100644 index 0000000000000..a39e34a21816d --- /dev/null +++ b/admin/tool/dataprivacy/tests/behat/user_data_request.feature @@ -0,0 +1,100 @@ +@tool @tool_dataprivacy +Feature: Authorized users can request others personal data + In order to export or access another users data + As a designated role + I need the correct permissions + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | user1 | User1 | One | user1@example.com | + | user2 | User2 | Two | user2@example.com | + | officer1 | Officer1 | One | officer1@example.com | + # Create Privacy Officer Role. + And the following "role" exists: + | shortname | privacyofficer | + | name | Privacy Officer | + | context_system | 1 | + | tool/dataprivacy:managedataregistry | allow | + | tool/dataprivacy:managedatarequests | allow | + | tool/dataprivacy:makedatarequestsforchildren | allow | + | moodle/site:configview | allow | + | moodle/category:viewhiddencategories | allow | + | moodle/course:viewhiddencourses | allow | + | moodle/course:viewhiddenactivities | allow | + | moodle/course:view | allow | + # Create Parent Role. + And the following "role" exists: + | shortname | parentrole | + | name | Parent Role | + | context_user | 1 | + | moodle/user:viewdetails | allow | + | moodle/user:viewalldetails | allow | + | moodle/user:readuserblogs | allow | + | moodle/user:readuserposts | allow | + | moodle/user:viewuseractivitiesreport | allow | + | moodle/user:editprofile | allow | + | tool/policy:acceptbehalf | allow | + | tool/dataprivacy:makedatarequestsforchildren | allow | + # Add permission to allow parent to make requests on behalf of child user. + And the following config values are set as admin: + | contactdataprotectionofficer | 1 | tool_dataprivacy | + And I log in as "admin" + + @javascript + Scenario: Privacy officer can request for other user's personal data + Given I navigate to "Users > Permissions > Assign system roles" in site administration + # Assign Privacy Officer role to officer1. + And I follow "Privacy Officer" + And I set the field "addselect_searchtext" to "Officer1" + And I set the field "addselect" to "Officer1 One (officer1@example.com)" + And I press "Add" + # Navigate to home in order to navigate properly to Privacy settings. + And I am on site homepage + # Select Privacy officer in the Orivacy officer role mapping setting. + And I navigate to "Users > Privacy and policies > Privacy settings" in site administration + And I click on "Privacy Officer" "checkbox" + And I press "Save changes" + And I log in as "officer1" + And I navigate to "Users > Privacy and policies > Data requests" in site administration + # Create a new request as the designated privacy officer. + When I follow "New request" + And I set the field "User" to "User1 One" + And I set the field "Comment" to "User One data" + And I press "Save changes" + # Confirm that the new data request is successfully created for selected user with status "Awaiting approval". + Then the following should exist in the "generaltable" table: + | Type | User | Requested by | Status | Message | + | Export | User1 One | Officer1 One | Awaiting approval | User One data | + + @javascript + Scenario: Parent user can request data on behalf of child user + Given I navigate to "Users > Accounts > Browse list of users" in site administration + And I follow "User1 One" + And I click on "Preferences" "link" in the ".profile_tree" "css_element" + # Assign user2 as parent for user1. + And I follow "Assign roles relative to this user" + And I follow "Parent" + And I set the field "Potential users" to "User2 Two (user2@example.com)" + And I click on "Add" "button" in the "#page-content" "css_element" + And I log in as "user2" + And I follow "Profile" in the user menu + And I follow "Data requests" + # As parent, create a data request for a child user. + And I follow "New request" + And I click on "User" "field" + When I type "User1 One" + # Confirm that only the parent's child users can be searched and selected. + Then I should see "User1 One" + And I type "User2 Two" + And I should see "No suggestions" + And I type "Officer1 One" + And I should see "No suggestions" + And I set the field "Search" to "User1" + And I set the field "Comment" to "This is a comment" + And I press "Save changes" + # Confirm that data request was successfully made by parent on behalf of child user. + And I should see "Your request has been submitted to the privacy officer" + And the following should exist in the "generaltable" table: + | Type | Requested by | Status | Message | + | Export all of my personal data (User1 One) | User2 Two | Awaiting approval | This is a comment | diff --git a/admin/tool/generator/lang/en/tool_generator.php b/admin/tool/generator/lang/en/tool_generator.php index 2dff165b957b6..30c0fcd6292c6 100644 --- a/admin/tool/generator/lang/en/tool_generator.php +++ b/admin/tool/generator/lang/en/tool_generator.php @@ -103,7 +103,7 @@ $string['testscenario_description'] = 'Creating testing scenarios uses a limited feature files syntax to create all necessary elements to run a manual test.'; $string['testscenario_filedesc'] = 'The upload feature files can only contain scenarios with core_data_generator steps or some specific steps that do not require selenium. All scenarios will be executed at once except the ones with @cleanup tag.'; $string['testscenario_filedesc_cleanup'] = 'Scenarios with @cleanup tag will be executed only if the "Execute" setting -is set to "Cleanup". To execute the cleanup via CLI, you can use the --cleanup option.'; +is set to "Cleanup". To execute the cleanup via Command Line Interface (CLI), use the --cleanup option.'; $string['testscenario_filedesc_list'] = 'This is the list of steps that can be used in the test scenario feature file:'; $string['testscenario_errorparsing'] = 'Error parsing feature file: {$a}'; $string['testscenario_file'] = 'Feature file'; diff --git a/admin/tool/installaddon/amd/build/footer.min.js b/admin/tool/installaddon/amd/build/footer.min.js new file mode 100644 index 0000000000000..66abfc988a59d --- /dev/null +++ b/admin/tool/installaddon/amd/build/footer.min.js @@ -0,0 +1,10 @@ +define("tool_installaddon/footer",["exports","jquery","core_course/local/activitychooser/selectors"],(function(_exports,_jquery,_selectors){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}} +/** + * Activity chooser footer handlers for tool_installaddon. + * + * @module tool_installaddon/footer + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.footerClickListener=void 0,_jquery=_interopRequireDefault(_jquery),_selectors=_interopRequireDefault(_selectors);_exports.footerClickListener=(e,footerData,modal)=>{const closeOption=e.target.closest(_selectors.default.actions.closeOption);if(!closeOption)return;const moduleName=closeOption.dataset.modname;if(!moduleName)return;const carousel=(0,_jquery.default)(modal.getBody()[0].querySelector(_selectors.default.regions.carousel));carousel.carousel("prev"),modal.setFooter(footerData.customfootertemplate),carousel.one("slid.bs.carousel",(()=>{const caller=modal.getBody()[0].querySelector(_selectors.default.regions.modules).querySelector(_selectors.default.regions.getModuleSelector(moduleName));caller&&caller.focus()}))}})); + +//# sourceMappingURL=footer.min.js.map \ No newline at end of file diff --git a/admin/tool/installaddon/amd/build/footer.min.js.map b/admin/tool/installaddon/amd/build/footer.min.js.map new file mode 100644 index 0000000000000..05ec44aa8e8eb --- /dev/null +++ b/admin/tool/installaddon/amd/build/footer.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"footer.min.js","sources":["../src/footer.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 * Activity chooser footer handlers for tool_installaddon.\n *\n * @module tool_installaddon/footer\n * @copyright 2026 Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport selectors from 'core_course/local/activitychooser/selectors';\n\n/**\n * Handle clicks in the chooser footer when installaddon is the active footer plugin.\n *\n * @param {Event} e The event being triggered\n * @param {Object} footerData The footer data generated for the chooser\n * @param {Object} modal The chooser modal\n */\nexport const footerClickListener = (e, footerData, modal) => {\n const closeOption = e.target.closest(selectors.actions.closeOption);\n\n if (!closeOption) {\n return;\n }\n\n const moduleName = closeOption.dataset.modname;\n\n if (!moduleName) {\n return;\n }\n\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n // Trigger the transition between 'pages'.\n carousel.carousel('prev');\n modal.setFooter(footerData.customfootertemplate);\n carousel.one('slid.bs.carousel', () => {\n const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);\n const caller = allModules.querySelector(selectors.regions.getModuleSelector(moduleName));\n if (caller) {\n caller.focus();\n }\n });\n};\n"],"names":["e","footerData","modal","closeOption","target","closest","selectors","actions","moduleName","dataset","modname","carousel","getBody","querySelector","regions","setFooter","customfootertemplate","one","caller","modules","getModuleSelector","focus"],"mappings":";;;;;;;oNAiCmC,CAACA,EAAGC,WAAYC,eACzCC,YAAcH,EAAEI,OAAOC,QAAQC,mBAAUC,QAAQJ,iBAElDA,yBAICK,WAAaL,YAAYM,QAAQC,YAElCF,wBAICG,UAAW,mBAAET,MAAMU,UAAU,GAAGC,cAAcP,mBAAUQ,QAAQH,WAGtEA,SAASA,SAAS,QAClBT,MAAMa,UAAUd,WAAWe,sBAC3BL,SAASM,IAAI,oBAAoB,WAEvBC,OADahB,MAAMU,UAAU,GAAGC,cAAcP,mBAAUQ,QAAQK,SAC5CN,cAAcP,mBAAUQ,QAAQM,kBAAkBZ,aACxEU,QACAA,OAAOG"} \ No newline at end of file diff --git a/admin/tool/installaddon/amd/src/footer.js b/admin/tool/installaddon/amd/src/footer.js new file mode 100644 index 0000000000000..65ddfeb90f89f --- /dev/null +++ b/admin/tool/installaddon/amd/src/footer.js @@ -0,0 +1,59 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Activity chooser footer handlers for tool_installaddon. + * + * @module tool_installaddon/footer + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import $ from 'jquery'; +import selectors from 'core_course/local/activitychooser/selectors'; + +/** + * Handle clicks in the chooser footer when installaddon is the active footer plugin. + * + * @param {Event} e The event being triggered + * @param {Object} footerData The footer data generated for the chooser + * @param {Object} modal The chooser modal + */ +export const footerClickListener = (e, footerData, modal) => { + const closeOption = e.target.closest(selectors.actions.closeOption); + + if (!closeOption) { + return; + } + + const moduleName = closeOption.dataset.modname; + + if (!moduleName) { + return; + } + + const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel)); + + // Trigger the transition between 'pages'. + carousel.carousel('prev'); + modal.setFooter(footerData.customfootertemplate); + carousel.one('slid.bs.carousel', () => { + const allModules = modal.getBody()[0].querySelector(selectors.regions.modules); + const caller = allModules.querySelector(selectors.regions.getModuleSelector(moduleName)); + if (caller) { + caller.focus(); + } + }); +}; diff --git a/admin/tool/installaddon/classes/installer.php b/admin/tool/installaddon/classes/installer.php index e8a4e150fb860..80f04bc9b4ed7 100644 --- a/admin/tool/installaddon/classes/installer.php +++ b/admin/tool/installaddon/classes/installer.php @@ -70,6 +70,25 @@ public function get_addons_repository_url() { $url = 'https://moodle.org/plugins/get.php'; } + return $this->get_external_service_url($url); + } + + /** + * Returns URL to Moodle Marketplace. + * + * @return moodle_url + */ + public function get_marketplace_url(): moodle_url { + return $this->get_external_service_url('https://marketplace.moodle.com/'); + } + + /** + * Returns URL to external service with optional encoded site information. + * + * @param string $url The external service base URL. + * @return moodle_url + */ + protected function get_external_service_url(string $url): moodle_url { if (!$this->should_send_site_info()) { return new moodle_url($url); } diff --git a/admin/tool/moodlenet/classes/task/post_install.php b/admin/tool/installaddon/classes/task/post_install.php similarity index 76% rename from admin/tool/moodlenet/classes/task/post_install.php rename to admin/tool/installaddon/classes/task/post_install.php index 55f2ca1388192..c4898e536ce07 100644 --- a/admin/tool/moodlenet/classes/task/post_install.php +++ b/admin/tool/installaddon/classes/task/post_install.php @@ -16,20 +16,23 @@ declare(strict_types=1); -namespace tool_moodlenet\task; +namespace tool_installaddon\task; /** * Ad-hoc task to perform post install tasks. - * We use this to set the active activity chooser footer plugin to tool_moodlenet. + * We use this to set the active activity chooser footer plugin to tool_installaddon. * We couldn't do this directly in install.php, because there is an admin_apply_default_settings() call after all plugins are * installed and that would reset whatever value we had set earlier to 'hidden'. * - * @package tool_moodlenet - * @copyright 2022 Shamim Rezaie + * @package tool_installaddon + * @copyright 2026 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class post_install extends \core\task\adhoc_task { - public function execute() { - set_config('activitychooseractivefooter', 'tool_moodlenet'); + /** + * Sets the active activity chooser footer plugin after install defaults are applied. + */ + public function execute(): void { + set_config('activitychooseractivefooter', 'tool_installaddon'); } } diff --git a/admin/tool/installaddon/db/install.php b/admin/tool/installaddon/db/install.php new file mode 100644 index 0000000000000..fa848ae2cd699 --- /dev/null +++ b/admin/tool/installaddon/db/install.php @@ -0,0 +1,37 @@ +. + +/** + * Install script for tool_installaddon. + * + * @package tool_installaddon + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Perform the post-install procedures. + */ +function xmldb_tool_installaddon_install(): void { + // Use an ad-hoc task to set the active activity chooser footer plugin to tool_installaddon. + // We couldn't do this in admin/settings/courses.php for 2 reasons: + // - First, because it would be a breach of component communications principles to do so there. + // - Second, because we can't call get_plugins_with_function() during install and upgrade (or it will return []). + // We couldn't do this directly here either, because there is an admin_apply_default_settings() call after all plugins are + // installed and that would reset whatever value we set here to 'hidden'. + $postinstall = new tool_installaddon\task\post_install(); + core\task\manager::queue_adhoc_task($postinstall); +} diff --git a/admin/tool/installaddon/db/upgrade.php b/admin/tool/installaddon/db/upgrade.php new file mode 100644 index 0000000000000..8fbcbcfcd627d --- /dev/null +++ b/admin/tool/installaddon/db/upgrade.php @@ -0,0 +1,41 @@ +. + +/** + * Upgrade script for tool_installaddon. + * + * @package tool_installaddon + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Upgrade the plugin. + * + * @param int $oldversion + * @return bool always true + */ +function xmldb_tool_installaddon_upgrade(int $oldversion): bool { + if ($oldversion < 2025041401) { + // Set the activity chooser active footer to include marketplace regardless of the previous setting. + // We are deliberately setting this to increase awareness of marketplace. + set_config('activitychooseractivefooter', 'tool_installaddon'); + + upgrade_plugin_savepoint(true, 2025041401, 'tool', 'installaddon'); + } + + return true; +} diff --git a/admin/tool/installaddon/lang/en/tool_installaddon.php b/admin/tool/installaddon/lang/en/tool_installaddon.php index e0dac15caa8d5..c104eb0b72363 100644 --- a/admin/tool/installaddon/lang/en/tool_installaddon.php +++ b/admin/tool/installaddon/lang/en/tool_installaddon.php @@ -28,6 +28,7 @@ $string['acknowledgement'] = 'Acknowledgement'; $string['acknowledgementtext'] = 'I understand that it is my responsibility to have full backups of this site prior to installing additional plugins. I accept and understand that plugins (especially but not only those originating in unofficial sources) may contain security holes, can make the site unavailable, or cause private data leaks or loss.'; +$string['activitychooserfootertext'] = 'Browse more activities on'; $string['featuredisabled'] = 'The plugin installer is disabled on this site.'; $string['installaddon'] = 'Install plugin!'; $string['installaddons'] = 'Install plugins'; @@ -44,6 +45,8 @@ $string['installfromziptype'] = 'Plugin type'; $string['installfromziptype_help'] = 'For plugins that correctly declare their component name, the installer is able to detect the plugin type automatically. If the auto-detection fails, choose the correct type of plugin manually. Warning: The installation procedure can fail badly if an incorrect plugin type is specified.'; $string['installfromziptype_link'] = 'Development:Plugins'; +$string['marketplaceadminlinktext'] = 'Browse new plugins'; +$string['marketplacelink'] = 'Moodle Marketplace'; $string['permcheck'] = 'Make sure the plugin type root location is writable by the web server process.'; $string['permcheckerror'] = 'Error while checking for write permission'; $string['permcheckprogress'] = 'Checking for write permission ...'; diff --git a/admin/tool/installaddon/lib.php b/admin/tool/installaddon/lib.php new file mode 100644 index 0000000000000..ebeecf1d10ebf --- /dev/null +++ b/admin/tool/installaddon/lib.php @@ -0,0 +1,48 @@ +. + +/** + * Public API for tool_installaddon. + * + * @package tool_installaddon + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_course\local\entity\activity_chooser_footer; + +/** + * Build activity chooser footer content for Marketplace. + * + * @param int $courseid The selected course id. + * @param int $sectionid The selected section id. + * @return activity_chooser_footer + */ +function tool_installaddon_custom_chooser_footer(int $courseid, int $sectionid): activity_chooser_footer { + global $OUTPUT; + + $installer = tool_installaddon_installer::instance(); + $marketplaceurl = $installer->get_marketplace_url(); + + $renderedfooter = $OUTPUT->render_from_template('tool_installaddon/chooser_footer', [ + 'url' => $marketplaceurl->out(false), + ]); + + return new activity_chooser_footer( + 'tool_installaddon/footer', + $renderedfooter + ); +} diff --git a/admin/tool/installaddon/settings.php b/admin/tool/installaddon/settings.php index 17f0d51e91dbb..8a42859bb4148 100644 --- a/admin/tool/installaddon/settings.php +++ b/admin/tool/installaddon/settings.php @@ -26,6 +26,12 @@ defined('MOODLE_INTERNAL') || die(); if ($hassiteconfig and empty($CFG->disableupdateautodeploy)) { + $installer = tool_installaddon_installer::instance(); + $ADMIN->add('modules', new admin_externalpage( + 'tool_installaddon_marketplace', + get_string('marketplaceadminlinktext', 'tool_installaddon'), + $installer->get_marketplace_url()->out(false) + ), 'modsettings'); $ADMIN->add('modules', new admin_externalpage('tool_installaddon_index', get_string('installaddons', 'tool_installaddon'), diff --git a/admin/tool/installaddon/templates/chooser_footer.mustache b/admin/tool/installaddon/templates/chooser_footer.mustache new file mode 100644 index 0000000000000..fb88aa65c249a --- /dev/null +++ b/admin/tool/installaddon/templates/chooser_footer.mustache @@ -0,0 +1,30 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_installaddon/chooser_footer + + Activity chooser footer link to Marketplace. + + Example context (json): + { + "url": "https://marketplace.moodle.com/?site=hash" + } +}} +
+ {{#str}} activitychooserfootertext, tool_installaddon {{/str}} + {{> tool_installaddon/marketplace_link }} +
diff --git a/admin/tool/installaddon/templates/marketplace_link.mustache b/admin/tool/installaddon/templates/marketplace_link.mustache new file mode 100644 index 0000000000000..5ac949b80f971 --- /dev/null +++ b/admin/tool/installaddon/templates/marketplace_link.mustache @@ -0,0 +1,36 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_installaddon/marketplace_link + + Reusable link to Moodle Marketplace. + + Example context (json): + { + "url": "https://marketplace.moodle.com/?site=hash" + } +}} + + {{#str}} marketplacelink, tool_installaddon {{/str}} + + + diff --git a/admin/tool/installaddon/tests/behat/activity_chooser_footer.feature b/admin/tool/installaddon/tests/behat/activity_chooser_footer.feature new file mode 100644 index 0000000000000..745b6a6364cfe --- /dev/null +++ b/admin/tool/installaddon/tests/behat/activity_chooser_footer.feature @@ -0,0 +1,37 @@ +@tool @tool_installaddon @javascript +Feature: Marketplace activity chooser footer + In order to browse plugins from the activity chooser + As a teacher + I need to see the Marketplace footer link when installaddon is the active footer plugin + + Background: + Given the following config values are set as admin: + | activitychooseractivefooter | tool_installaddon | + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | category | format | + | Course 1 | C1 | 0 | topics | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + + Scenario: Activity chooser footer includes Marketplace link + Given I log in as "teacher1" + When I am on "Course 1" course homepage with editing mode on + And I open the activity chooser + Then "Browse more activities on" "text" should exist in the "Add an activity or resource" "dialogue" + And "Marketplace" "link" should exist in the "Add an activity or resource" "dialogue" + + Scenario: Back button works from activity help when the Marketplace footer is active + Given I log in as "teacher1" + When I am on "Course 1" course homepage with editing mode on + And I open the activity chooser + And I click on "Information about the Assignment activity" "button" in the "Add an activity or resource" "dialogue" + Then "help" "core_course > Activity chooser screen" should be visible + When I click on "Back" "button" in the "Add an activity or resource" "dialogue" + Then "modules" "core_course > Activity chooser screen" should be visible + And "help" "core_course > Activity chooser screen" should not be visible + And "Marketplace" "link" should exist in the "Add an activity or resource" "dialogue" + And "Back" "button" should not exist in the "Add an activity or resource" "dialogue" diff --git a/admin/tool/installaddon/tests/fixtures/testable_installer_without_site_info.php b/admin/tool/installaddon/tests/fixtures/testable_installer_without_site_info.php new file mode 100644 index 0000000000000..2e7223663f78e --- /dev/null +++ b/admin/tool/installaddon/tests/fixtures/testable_installer_without_site_info.php @@ -0,0 +1,46 @@ +. + +/** + * Provides a mock testable_tool_installaddon_installer_without_site_info class. + * + * @package tool_installaddon + * @subpackage fixtures + * @category test + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/testable_installer.php'); + +/** + * Testable subclass with site-info sharing disabled. + * + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class testable_tool_installaddon_installer_without_site_info extends testable_tool_installaddon_installer { + /** + * Disable site info sharing. + * + * @return bool + */ + protected function should_send_site_info() { + return false; + } +} diff --git a/admin/tool/installaddon/tests/installer_test.php b/admin/tool/installaddon/tests/installer_test.php index f0f2fa2af3e9d..ac2645c0f142d 100644 --- a/admin/tool/installaddon/tests/installer_test.php +++ b/admin/tool/installaddon/tests/installer_test.php @@ -18,12 +18,14 @@ namespace tool_installaddon; use testable_tool_installaddon_installer; +use testable_tool_installaddon_installer_without_site_info; use tool_installaddon_installer; defined('MOODLE_INTERNAL') || die(); global $CFG; -require_once(__DIR__.'/fixtures/testable_installer.php'); +require_once(__DIR__ . '/fixtures/testable_installer.php'); +require_once(__DIR__ . '/fixtures/testable_installer_without_site_info.php'); /** * Unit tests for the {@link tool_installaddon_installer} class @@ -32,16 +34,14 @@ * @category test * @copyright 2013 David Mudrak * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tool_installaddon_installer */ final class installer_test extends \advanced_testcase { public function test_get_addons_repository_url(): void { $installer = testable_tool_installaddon_installer::instance(); $url = $installer->get_addons_repository_url(); - $query = parse_url($url, PHP_URL_QUERY); - $this->assertEquals(1, preg_match('~^site=(.+)$~', $query, $matches)); - $site = rawurldecode($matches[1]); - $site = json_decode(base64_decode($site), true); + $site = $this->decode_site_info_from_url($url); $this->assertIsArray($site); $this->assertEquals(3, count($site)); $this->assertSame('Nasty site', $site['fullname']); @@ -49,6 +49,34 @@ public function test_get_addons_repository_url(): void { $this->assertSame("2.5'; DROP TABLE mdl_user; --", $site['majorversion']); } + /** + * Tests that marketplace URL includes expected host, scheme and site payload. + */ + public function test_get_marketplace_url(): void { + $installer = testable_tool_installaddon_installer::instance(); + $addonsurl = $installer->get_addons_repository_url(); + $marketplaceurl = $installer->get_marketplace_url(); + + $this->assertSame('marketplace.moodle.com', parse_url($marketplaceurl, PHP_URL_HOST)); + $this->assertSame('https', parse_url($marketplaceurl, PHP_URL_SCHEME)); + $this->assertSame( + $this->decode_site_info_from_url($addonsurl), + $this->decode_site_info_from_url($marketplaceurl), + ); + } + + /** + * Tests that marketplace URL has no query string when site info is unavailable. + */ + public function test_get_marketplace_url_without_site_info(): void { + $installer = testable_tool_installaddon_installer_without_site_info::instance(); + $marketplaceurl = $installer->get_marketplace_url(); + + $this->assertSame('marketplace.moodle.com', parse_url($marketplaceurl, PHP_URL_HOST)); + $this->assertSame('https', parse_url($marketplaceurl, PHP_URL_SCHEME)); + $this->assertEmpty(parse_url($marketplaceurl, PHP_URL_QUERY)); + } + public function test_decode_remote_request(): void { $installer = testable_tool_installaddon_installer::instance(); @@ -132,7 +160,7 @@ public function test_detect_plugin_component_from_versionphp(): void { $versionphp = file_get_contents($fixtures.'/github/moodle-repository_mahara-master/version.php'); $this->assertEquals('repository_mahara', $installer->testable_detect_plugin_component_from_versionphp($versionphp)); - $versionphp = file_get_contents($fixtures.'/nocomponent/baz/version.php'); + $versionphp = file_get_contents($fixtures . '/nocomponent/baz/version.php'); $this->assertFalse($installer->testable_detect_plugin_component_from_versionphp($versionphp)); } @@ -143,15 +171,28 @@ public function test_make_installfromzip_storage(): void { $storage1 = $installer->make_installfromzip_storage(); $this->assertTrue(is_dir($storage1)); $this->assertTrue(is_writable($storage1)); - file_put_contents($storage1.'/hello.txt', 'Find me if you can!'); + file_put_contents($storage1 . '/hello.txt', 'Find me if you can!'); // Check we get unique directory on each call. $storage2 = $installer->make_installfromzip_storage(); $this->assertTrue(is_dir($storage2)); $this->assertTrue(is_writable($storage2)); - $this->assertFalse(file_exists($storage2.'/hello.txt')); + $this->assertFalse(file_exists($storage2 . '/hello.txt')); // Check both are in the same parent directory. $this->assertEquals(dirname($storage1), dirname($storage2)); } + + /** + * Decodes the encoded 'site' URL query parameter. + * + * @param moodle_url $url + * @return array + */ + private function decode_site_info_from_url(\moodle_url $url): array { + $query = parse_url($url, PHP_URL_QUERY); + $this->assertEquals(1, preg_match('~^site=(.+)$~', $query, $matches)); + $site = rawurldecode($matches[1]); + return json_decode(base64_decode($site), true); + } } diff --git a/admin/tool/installaddon/tests/lib_test.php b/admin/tool/installaddon/tests/lib_test.php new file mode 100644 index 0000000000000..d215534a4eb5b --- /dev/null +++ b/admin/tool/installaddon/tests/lib_test.php @@ -0,0 +1,53 @@ +. + +/** + * Unit tests for tool_installaddon lib. + * + * @package tool_installaddon + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_installaddon; + +use core_course\local\entity\activity_chooser_footer; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/admin/tool/installaddon/lib.php'); + +/** + * Test installaddon lib functions. + */ +final class lib_test extends \advanced_testcase { + /** + * Tests chooser footer generation for marketplace link content. + * + * @covers ::tool_installaddon_custom_chooser_footer + */ + public function test_tool_installaddon_custom_chooser_footer(): void { + $this->resetAfterTest(); + + $footer = \tool_installaddon_custom_chooser_footer(1, 1); + + $this->assertInstanceOf(activity_chooser_footer::class, $footer); + $this->assertSame('tool_installaddon/footer', $footer->get_footer_js_file()); + $this->assertStringContainsString('https://marketplace.moodle.com/', $footer->get_footer_template()); + $this->assertStringContainsString('site=', $footer->get_footer_template()); + } +} diff --git a/admin/tool/installaddon/tests/task/post_install_test.php b/admin/tool/installaddon/tests/task/post_install_test.php new file mode 100644 index 0000000000000..9a984ad5f2cb5 --- /dev/null +++ b/admin/tool/installaddon/tests/task/post_install_test.php @@ -0,0 +1,47 @@ +. + +declare(strict_types=1); + +namespace tool_installaddon\task; + +/** + * Unit tests for the post install task. + * + * @package tool_installaddon + * @category test + * @copyright 2026 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \tool_installaddon\task\post_install + */ +final class post_install_test extends \advanced_testcase { + /** + * Tests that the task sets the activity chooser footer plugin to tool_installaddon. + */ + public function test_execute_sets_activitychooseractivefooter_config(): void { + $this->resetAfterTest(); + + set_config('activitychooseractivefooter', 'hidden'); + + $task = new post_install(); + $task->execute(); + + $this->assertSame( + 'tool_installaddon', + get_config('core', 'activitychooseractivefooter'), + ); + } +} diff --git a/admin/tool/installaddon/version.php b/admin/tool/installaddon/version.php index 8722c166b372b..2ad5d8eff23f8 100644 --- a/admin/tool/installaddon/version.php +++ b/admin/tool/installaddon/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'tool_installaddon'; -$plugin->version = 2025041400; +$plugin->version = 2025041401; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; diff --git a/admin/tool/lp/templates/plans_page.mustache b/admin/tool/lp/templates/plans_page.mustache index 17e0d4bad2fb5..4dbf02ebd965b 100644 --- a/admin/tool/lp/templates/plans_page.mustache +++ b/admin/tool/lp/templates/plans_page.mustache @@ -71,17 +71,13 @@
  • {{#str}}edit{{/str}}